mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor(ui): migrate the last antd components off antd onto shadcn (#37569)
Converts the remaining dashboard components that still imported antd: admin panel, agents, MCP toolsets, policies, prompts, bulk user edit, create user, plugin settings, teams, add model, auto router, cloudzero export, BYOK credentials, credential modal, onboarding link, create key and routing groups. Primitives map onto the house shadcn set: Typography onto semantic tags, Select onto ui/select, SearchSelect or MultiSelect, Input onto ui/input, Tooltip onto SimpleTooltip, Card, Table, Tabs, Switch, Checkbox, Radio, Tag onto Badge, Divider onto Separator, Spin onto UiLoadingSpinner, Modal onto Dialog, message onto toast, and Space, Row, Col, Flex and Layout onto flex containers.
This commit is contained in:
parent
c2b3c4b1e4
commit
0b374541bb
34 changed files with 1513 additions and 1431 deletions
|
|
@ -12,23 +12,21 @@ export async function createMcpServer(page: PwPage, url: string): Promise<string
|
|||
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 });
|
||||
|
||||
// validateMCPServerName rejects spaces and hyphens; the worker index avoids a same-millisecond collision.
|
||||
const name = `e2e_mcp_${process.env.TEST_WORKER_INDEX ?? "0"}_${Date.now()}`;
|
||||
await formModal.locator('input[id="server_name"]').fill(name);
|
||||
await formModal.getByLabel("MCP Server Name").fill(name);
|
||||
|
||||
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();
|
||||
// Select popups are portaled to the body, so the option lookup is page-scoped, not modal-scoped.
|
||||
await formModal.getByRole("combobox", { name: "Transport Type" }).click();
|
||||
await page.getByRole("option", { name: "Streamable HTTP" }).click();
|
||||
|
||||
await formModal.locator('input[id="url"]').fill(url);
|
||||
await formModal.getByLabel("MCP Server URL").fill(url);
|
||||
|
||||
// The auth_type Form.Item has no label prop, so anchor on the enclosing Collapse panel.
|
||||
const authSection = formModal.locator(".ant-collapse-item", { hasText: /^Authentication/ });
|
||||
await authSection.locator(".ant-form-item").first().locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("None", { exact: true }).click();
|
||||
await formModal.getByRole("combobox", { name: "Authentication", exact: true }).click();
|
||||
await page.getByRole("option", { name: "None", exact: true }).click();
|
||||
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
await expect(page.getByText("MCP Server created successfully").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
|
|
|||
|
|
@ -30,31 +30,26 @@ test.describe("MCP Servers", () => {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ async function openUsage(page: PlaywrightPage): Promise<Locator> {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<AdminPanelProps> = ({ proxySettings }) => {
|
|||
children: (
|
||||
<>
|
||||
<Card className="block p-6">
|
||||
<Title level={4}> ✨ Security Settings</Title>
|
||||
<h3 className="mb-2 text-base font-semibold text-foreground">✨ Security Settings</h3>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>SSO Configuration Deprecated</AlertTitle>
|
||||
|
|
@ -329,7 +327,9 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Text>Are you sure you want to delete the IP address: {ipToDelete}?</Text>
|
||||
<span className="text-sm text-foreground">
|
||||
Are you sure you want to delete the IP address: {ipToDelete}?
|
||||
</span>
|
||||
<DialogFooter>
|
||||
<Button className="mx-1" onClick={() => confirmDeleteIP()}>
|
||||
Yes
|
||||
|
|
@ -379,11 +379,10 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
{
|
||||
key: "ui-settings",
|
||||
label: (
|
||||
<Space>
|
||||
<Text>
|
||||
UI Settings <NewBadge />
|
||||
</Text>
|
||||
</Space>
|
||||
<span className="flex items-center gap-1.5">
|
||||
UI Settings
|
||||
<NewBadge />
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -411,9 +410,22 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
|
||||
return (
|
||||
<div className="w-full m-2 mt-2 p-8">
|
||||
<Title level={4}>Admin Access </Title>
|
||||
<Paragraph>Go to 'Internal Users' page to add other admins.</Paragraph>
|
||||
<Tabs items={tabItems} />
|
||||
<h2 className="mb-2 text-base font-semibold text-foreground">Admin Access</h2>
|
||||
<p className="mb-4 text-sm text-foreground">Go to 'Internal Users' page to add other admins.</p>
|
||||
<Tabs defaultValue={tabItems[0].key}>
|
||||
<TabsList variant="line" className="mb-4 h-auto flex-wrap">
|
||||
{tabItems.map((item) => (
|
||||
<TabsTrigger key={item.key} value={item.key} className="flex-none">
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabItems.map((item) => (
|
||||
<TabsContent key={item.key} value={item.key}>
|
||||
{item.children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import AddAgentForm from "./add_agent_form";
|
||||
import * as networking from "@/components/networking";
|
||||
|
|
@ -50,7 +51,7 @@ describe("AddAgentForm logos", () => {
|
|||
expect(titleLogo).toBeInstanceOf(HTMLImageElement);
|
||||
expect(titleLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
|
||||
|
||||
const selectionLogo = await screen.findByAltText("A2A Agent logo");
|
||||
const selectionLogo = within(await screen.findByRole("combobox")).getByAltText("A2A Agent logo");
|
||||
expect(selectionLogo).toBeInstanceOf(HTMLImageElement);
|
||||
expect(selectionLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
|
||||
});
|
||||
|
|
@ -64,10 +65,12 @@ describe("AddAgentForm logos", () => {
|
|||
});
|
||||
|
||||
it("renders the option logo when the agent type dropdown is opened", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderForm();
|
||||
|
||||
await screen.findByAltText("A2A Agent logo");
|
||||
fireEvent.mouseDown(screen.getByRole("combobox"));
|
||||
const trigger = await screen.findByRole("combobox");
|
||||
await within(trigger).findByAltText("A2A Agent logo");
|
||||
await user.click(trigger);
|
||||
|
||||
const optionLogos = await screen.findAllByAltText("A2A Agent logo");
|
||||
expect(optionLogos.length).toBeGreaterThanOrEqual(2);
|
||||
|
|
@ -88,9 +91,9 @@ describe("AddAgentForm logos", () => {
|
|||
expect(screen.queryByAltText("Agent logo")).not.toBeInTheDocument();
|
||||
expect(within(header).getByText("A")).toBeInTheDocument();
|
||||
|
||||
const selectionLogo = screen.getByAltText("A2A Agent logo");
|
||||
fireEvent.error(selectionLogo);
|
||||
expect(screen.queryByAltText("A2A Agent logo")).not.toBeInTheDocument();
|
||||
const trigger = screen.getByRole("combobox");
|
||||
fireEvent.error(within(trigger).getByAltText("A2A Agent logo"));
|
||||
expect(within(trigger).queryByAltText("A2A Agent logo")).not.toBeInTheDocument();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Steps, Tag } from "antd";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { Bot, CircleCheck, Key, LayoutGrid } from "lucide-react";
|
||||
import { Bot, Check, CircleCheck, Key, LayoutGrid } from "lucide-react";
|
||||
import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -51,10 +52,61 @@ import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermis
|
|||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
const CUSTOM_AGENT_TYPE = "custom";
|
||||
|
||||
const STEP_TITLES = ["Configure", "Entitlements", "Governance", "Agent Management", "Ready"] as const;
|
||||
|
||||
const stepMarkerClass = (index: number, current: number): string => {
|
||||
if (index < current) return "border-primary text-primary";
|
||||
if (index === current) return "border-primary bg-primary text-primary-foreground";
|
||||
return "border-border text-muted-foreground";
|
||||
};
|
||||
|
||||
const stepTitleClass = (index: number, current: number): string => {
|
||||
if (index === current) return "font-medium text-foreground";
|
||||
if (index < current) return "text-foreground";
|
||||
return "text-muted-foreground";
|
||||
};
|
||||
|
||||
const AgentTypeLabel: React.FC<{ agentType: string; info: AgentCreateInfo | undefined }> = ({ agentType, info }) => {
|
||||
if (agentType === CUSTOM_AGENT_TYPE) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="size-4 text-amber-600 dark:text-amber-400" />
|
||||
<span>Custom / Other</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!info) return <>{agentType}</>;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Logo src={info.logo_url} label={info.agent_type_display_name} className="h-4 w-4 object-contain" />
|
||||
<span>{info.agent_type_display_name}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StepProgress: React.FC<{ current: number }> = ({ current }) => (
|
||||
<ol aria-label="Agent creation steps" className="mb-8 flex items-center">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
aria-current={index === current ? "step" : undefined}
|
||||
className="flex flex-1 items-center gap-2 last:flex-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded-full border text-xs ${stepMarkerClass(index, current)}`}
|
||||
>
|
||||
{index < current ? <Check className="size-3.5" /> : index + 1}
|
||||
</span>
|
||||
<span className={`text-xs whitespace-nowrap ${stepTitleClass(index, current)}`}>{title}</span>
|
||||
{index < STEP_TITLES.length - 1 && <span aria-hidden="true" className="mx-2 h-px flex-1 bg-border" />}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
|
||||
const SHARED_INITIAL_VALUES: AgentFormValues = {
|
||||
allowed_mcp_servers_and_groups: { servers: [], accessGroups: [] },
|
||||
mcp_tool_permissions: {},
|
||||
|
|
@ -684,66 +736,48 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<FieldLabel htmlFor="agent-type">
|
||||
{labelWithHint("Agent Type", "Select the type of agent you want to create")}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
id="agent-type"
|
||||
value={agentType}
|
||||
onChange={handleAgentTypeChange}
|
||||
size="large"
|
||||
style={{ width: "100%" }}
|
||||
optionLabelProp="label"
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Separator className="my-1" />
|
||||
<div className="px-2 py-1">
|
||||
<div className="mb-1 px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Not listed?
|
||||
</div>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-3 rounded px-2 py-2 transition-colors ${
|
||||
agentType === CUSTOM_AGENT_TYPE
|
||||
? "bg-amber-50 dark:bg-amber-950"
|
||||
: "hover:bg-amber-50 dark:hover:bg-amber-950"
|
||||
}`}
|
||||
onClick={() => handleAgentTypeChange(CUSTOM_AGENT_TYPE)}
|
||||
>
|
||||
<LayoutGrid className="size-4.5 text-amber-600 dark:text-amber-400" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400">Custom / Other</span>
|
||||
<Tag color="orange" style={{ fontSize: 10, padding: "0 4px" }}>
|
||||
GENERIC
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400">
|
||||
For agents that don't follow a standard protocol, just needs a virtual key
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{agentTypeMetadata.map((info) => (
|
||||
<Select.Option
|
||||
key={info.agent_type}
|
||||
value={info.agent_type}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo src={info.logo_url} label={info.agent_type_display_name} className="h-4 w-4 object-contain" />
|
||||
<span>{info.agent_type_display_name}</span>
|
||||
</div>
|
||||
}
|
||||
<Select value={agentType} onValueChange={(value) => value !== null && handleAgentTypeChange(value)}>
|
||||
<SelectTrigger id="agent-type" className="h-10 w-full">
|
||||
<SelectValue>{() => <AgentTypeLabel agentType={agentType} info={selectedAgentTypeInfo} />}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="p-1">
|
||||
{agentTypeMetadata.map((info) => (
|
||||
<SelectItem key={info.agent_type} value={info.agent_type}>
|
||||
<span className="flex items-center gap-3 py-1">
|
||||
<Logo src={info.logo_url} label={info.agent_type_display_name} className="h-5 w-5 object-contain" />
|
||||
<span className="block">
|
||||
<span className="block font-medium">{info.agent_type_display_name}</span>
|
||||
{info.description && (
|
||||
<span className="block text-xs text-muted-foreground">{info.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<div className="mb-1 px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Not listed?
|
||||
</div>
|
||||
<SelectItem
|
||||
value={CUSTOM_AGENT_TYPE}
|
||||
className="focus:bg-amber-50 dark:focus:bg-amber-950 dark:focus:**:text-amber-400"
|
||||
>
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<Logo src={info.logo_url} label={info.agent_type_display_name} className="h-5 w-5 object-contain" />
|
||||
<div>
|
||||
<div className="font-medium">{info.agent_type_display_name}</div>
|
||||
{info.description && <div className="text-xs text-muted-foreground">{info.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
<span className="flex items-center gap-3">
|
||||
<LayoutGrid className="size-4.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="block">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400">Custom / Other</span>
|
||||
<Badge variant="warning" className="h-4 px-1 text-[10px]">
|
||||
GENERIC
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="block text-xs whitespace-normal text-amber-600 dark:text-amber-400">
|
||||
For agents that don't follow a standard protocol, just needs a virtual key
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
|
|
@ -846,10 +880,10 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<div>
|
||||
{/* Agent name chip */}
|
||||
<div className="mb-6 flex justify-center">
|
||||
<Tag color="purple" className="inline-flex items-center gap-1.5 px-3 py-1 text-sm">
|
||||
<Badge className="h-auto gap-1.5 bg-purple-100 px-3 py-1 text-sm text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot className="size-3.5" />
|
||||
{agentName}
|
||||
</Tag>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<AgentFormField
|
||||
|
|
@ -904,7 +938,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Tag color="green">Recommended</Tag>
|
||||
<Badge variant="success">Recommended</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -962,10 +996,10 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<CircleCheck className="mb-4 size-12 text-green-500" />
|
||||
<h3 className="mb-2 text-xl font-semibold text-foreground">Agent Created!</h3>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<Tag color="purple" className="inline-flex items-center gap-1.5 px-3 py-1 text-sm">
|
||||
<Badge className="h-auto gap-1.5 bg-purple-100 px-3 py-1 text-sm text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot className="size-3.5" />
|
||||
{createdAgentName}
|
||||
</Tag>
|
||||
</Badge>
|
||||
</div>
|
||||
{createdKeyValue && (
|
||||
<div className="mx-auto mt-4 max-w-md text-left">
|
||||
|
|
@ -998,13 +1032,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
<div className="mt-4">
|
||||
<Steps current={currentStep} size="small" className="mb-8">
|
||||
<Step title="Configure" />
|
||||
<Step title="Entitlements" />
|
||||
<Step title="Governance" />
|
||||
<Step title="Agent Management" />
|
||||
<Step title="Ready" />
|
||||
</Steps>
|
||||
<StepProgress current={currentStep} />
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={(event) => event.preventDefault()} className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import { Input, message, Spin } from "antd";
|
||||
import { z } from "zod/v4";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox, Plus } from "lucide-react";
|
||||
import { Inbox, Plus, X } from "lucide-react";
|
||||
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -18,9 +17,11 @@ import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
|
|||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input as ShadcnInput } from "@/components/ui/input";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { displayToolName, getMCPToolsetTableColumns } from "./MCPToolsetTableColumns";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
|
|
@ -97,7 +98,7 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
|
|||
<div className="p-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-3">
|
||||
<Spin size="small" />
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-2 py-2">No tools found for this server.</p>
|
||||
|
|
@ -212,10 +213,10 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
|
|||
<form onSubmit={(event) => event.preventDefault()} className="mt-2">
|
||||
<FieldGroup className="mb-4 flex-row gap-4">
|
||||
<FormField control={form.control} name="toolset_name" label="Toolset Name" className="flex-1">
|
||||
{(field) => <ShadcnInput {...field} placeholder="e.g. github-linear-tools" />}
|
||||
{(field) => <Input {...field} placeholder="e.g. github-linear-tools" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="description" label="Description" className="flex-1">
|
||||
{(field) => <ShadcnInput {...field} placeholder="Optional description" />}
|
||||
{(field) => <Input {...field} placeholder="Optional description" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
|
@ -226,13 +227,20 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
|
|||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-foreground">Available Tools</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search MCP servers..."
|
||||
value={serverSearch}
|
||||
onChange={(e) => setServerSearch(e.target.value)}
|
||||
className="mb-2"
|
||||
allowClear
|
||||
/>
|
||||
<InputGroup className="mb-2">
|
||||
<InputGroupInput
|
||||
placeholder="Search MCP servers..."
|
||||
value={serverSearch}
|
||||
onChange={(e) => setServerSearch(e.target.value)}
|
||||
/>
|
||||
{serverSearch && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setServerSearch("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
|
||||
{filteredServers.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
|
@ -381,14 +389,14 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
const handleCreate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
|
||||
if (!accessToken) return;
|
||||
await createMCPToolset(accessToken, { toolset_name: name, description, tools });
|
||||
message.success("Toolset created");
|
||||
toast.success("Toolset created");
|
||||
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
|
||||
};
|
||||
|
||||
const handleUpdate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
|
||||
if (!accessToken || !editToolset) return;
|
||||
await updateMCPToolset(accessToken, { toolset_id: editToolset.toolset_id, toolset_name: name, description, tools });
|
||||
message.success("Toolset updated");
|
||||
toast.success("Toolset updated");
|
||||
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
|
||||
setEditToolset(null);
|
||||
};
|
||||
|
|
@ -398,7 +406,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
|
|||
setDeleting(true);
|
||||
try {
|
||||
await deleteMCPToolset(accessToken, deleteId);
|
||||
message.success("Toolset deleted");
|
||||
toast.success("Toolset deleted");
|
||||
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
|
||||
setDeleteId(null);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Tag } from "antd";
|
||||
import { z } from "zod/v4";
|
||||
import { Policy, PolicyCreateRequest, PolicyUpdateRequest } from "@/components/policies/types";
|
||||
import { Guardrail } from "@/components/guardrails/types";
|
||||
|
|
@ -474,9 +473,9 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
|
|||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{resolvedGuardrails.map((g) => (
|
||||
<Tag key={g} color="blue">
|
||||
<Badge key={g} variant="info">
|
||||
{g}
|
||||
</Tag>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Upload } from "antd";
|
||||
import type { UploadFile, UploadProps } from "antd";
|
||||
import { Upload as UploadIcon } from "lucide-react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Upload as UploadIcon, X } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import { convertPromptFileToJson, createPromptCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -50,25 +48,46 @@ const EMPTY_VALUES: AddPromptFormValues = { prompt_id: "", prompt_integration: "
|
|||
const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
|
||||
const form = useZodForm(addPromptSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [promptIntegration, setPromptIntegration] = useState<string>("dotprompt");
|
||||
|
||||
const clearSelectedFile = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.reset(EMPTY_VALUES);
|
||||
setFileList([]);
|
||||
clearSelectedFile();
|
||||
setPromptIntegration("dotprompt");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = event.target.files?.[0];
|
||||
if (!picked) return;
|
||||
if (!picked.name.endsWith(".prompt")) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
clearSelectedFile();
|
||||
return;
|
||||
}
|
||||
setSelectedFile(picked);
|
||||
};
|
||||
|
||||
const handleIntegrationChange = (selected: string | null) => {
|
||||
if (selected === null) return;
|
||||
form.setValue("prompt_integration", selected);
|
||||
setPromptIntegration(selected);
|
||||
};
|
||||
|
||||
const convertUploadedFile = async (token: string, promptId: string): Promise<CreatePromptRequest | null> => {
|
||||
const file = fileList[0].originFileObj as File;
|
||||
|
||||
const convertUploadedFile = async (
|
||||
token: string,
|
||||
promptId: string,
|
||||
file: File,
|
||||
): Promise<CreatePromptRequest | null> => {
|
||||
try {
|
||||
const conversionResult = await convertPromptFileToJson(token, file);
|
||||
|
||||
|
|
@ -98,16 +117,15 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
|
||||
const isDotprompt = promptIntegration === "dotprompt";
|
||||
|
||||
if (isDotprompt && fileList.length === 0) {
|
||||
if (isDotprompt && !selectedFile) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const promptData: CreatePromptRequest | Record<string, never> | null = isDotprompt
|
||||
? await convertUploadedFile(accessToken, values.prompt_id)
|
||||
: {};
|
||||
const promptData: CreatePromptRequest | Record<string, never> | null =
|
||||
isDotprompt && selectedFile ? await convertUploadedFile(accessToken, values.prompt_id, selectedFile) : {};
|
||||
|
||||
if (promptData === null) {
|
||||
setLoading(false);
|
||||
|
|
@ -127,23 +145,6 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
}
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
beforeUpload: (file) => {
|
||||
if (!file.name.endsWith(".prompt")) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
return false;
|
||||
}
|
||||
return false; // Prevent automatic upload
|
||||
},
|
||||
fileList,
|
||||
onChange: ({ fileList: newFileList }) => {
|
||||
setFileList(newFileList.slice(-1)); // Keep only the last file
|
||||
},
|
||||
onRemove: () => {
|
||||
setFileList([]);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[600px]">
|
||||
|
|
@ -180,14 +181,30 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
<FieldSeparator />
|
||||
<Field>
|
||||
<FieldTitle>Prompt File</FieldTitle>
|
||||
<Upload {...uploadProps}>
|
||||
<Button type="button" variant="outline">
|
||||
<UploadIcon />
|
||||
Select .prompt File
|
||||
</Button>
|
||||
</Upload>
|
||||
{fileList.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">Selected: {fileList[0].name}</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".prompt"
|
||||
aria-label="Prompt file"
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
<UploadIcon />
|
||||
Select .prompt File
|
||||
</Button>
|
||||
{selectedFile && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Selected: {selectedFile.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${selectedFile.name}`}
|
||||
onClick={clearSelectedFile}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FieldDescription>Upload a .prompt file that follows the Dotprompt specification</FieldDescription>
|
||||
</Field>
|
||||
|
|
@ -196,16 +213,13 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
</FieldGroup>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
{" "}
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
,
|
||||
<Button type="button" disabled={loading} onClick={() => void form.handleSubmit(handleSubmit)()}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Create Prompt
|
||||
</Button>
|
||||
, ]
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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<BulkEditUserModalProps> = ({
|
|||
const [teamBudget, setTeamBudget] = useState<number | null>(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<BulkEditUserModalProps> = ({
|
|||
</DialogHeader>
|
||||
{allowAllUsers && (
|
||||
<div className="mb-4">
|
||||
<Checkbox checked={updateAllUsers} onChange={(e) => setUpdateAllUsers(e.target.checked)}>
|
||||
<Text strong>Update ALL users in the system</Text>
|
||||
</Checkbox>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={updateAllUsersId}
|
||||
checked={updateAllUsers}
|
||||
onCheckedChange={(checked) => setUpdateAllUsers(checked === true)}
|
||||
aria-label="Update ALL users in the system"
|
||||
/>
|
||||
<label htmlFor={updateAllUsersId} className="cursor-pointer text-sm font-medium text-foreground">
|
||||
Update ALL users in the system
|
||||
</label>
|
||||
</div>
|
||||
{updateAllUsers && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="warning" style={{ fontSize: "12px" }}>
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-amber-600">
|
||||
⚠️ This will apply changes to ALL users in the system, not just the selected ones.
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -225,118 +240,115 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
|
||||
{!updateAllUsers && (
|
||||
<div className="mb-4">
|
||||
<Title level={5}>Selected Users ({selectedUsers.length}):</Title>
|
||||
<Table
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={selectedUsers}
|
||||
pagination={false}
|
||||
scroll={{ y: 200 }}
|
||||
rowKey="user_id"
|
||||
columns={[
|
||||
{
|
||||
title: "User ID",
|
||||
dataIndex: "user_id",
|
||||
key: "user_id",
|
||||
width: "30%",
|
||||
render: (text: string) => (
|
||||
<Text strong style={{ fontSize: "12px" }}>
|
||||
{text.length > 20 ? `${text.slice(0, 20)}...` : text}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Email",
|
||||
dataIndex: "user_email",
|
||||
key: "user_email",
|
||||
width: "25%",
|
||||
render: (text: string) => (
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
{text || "No email"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Current Role",
|
||||
dataIndex: "user_role",
|
||||
key: "user_role",
|
||||
width: "25%",
|
||||
render: (role: string) => (
|
||||
<Text style={{ fontSize: "12px" }}>{possibleUIRoles?.[role]?.ui_label || role}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Budget",
|
||||
dataIndex: "max_budget",
|
||||
key: "max_budget",
|
||||
width: "20%",
|
||||
render: (budget: number | null) => (
|
||||
<MoneyCell value={budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<h5 className="mb-2 text-sm font-semibold text-foreground">Selected Users ({selectedUsers.length}):</h5>
|
||||
<div className="max-h-[200px] overflow-y-auto rounded-md border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[30%]">User ID</TableHead>
|
||||
<TableHead className="w-[25%]">Email</TableHead>
|
||||
<TableHead className="w-[25%]">Current Role</TableHead>
|
||||
<TableHead className="w-[20%]">Budget</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedUsers.map((user) => (
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell className="text-xs font-medium text-foreground">
|
||||
{user.user_id.length > 20 ? `${user.user_id.slice(0, 20)}...` : user.user_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{user.user_email || "No email"}</TableCell>
|
||||
<TableCell className="text-xs text-foreground">
|
||||
{possibleUIRoles?.[user.user_role]?.ui_label || user.user_role}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={user.max_budget} decimals={2} emptyText="Unlimited" showZero />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="mb-4">
|
||||
<Text>
|
||||
<p className="text-sm text-foreground">
|
||||
<strong>Instructions:</strong> 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.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Team Management Section */}
|
||||
<Card title="Team Management" size="small" className="mb-4" style={{ backgroundColor: "#fafafa" }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Checkbox checked={addToTeams} onChange={(e) => setAddToTeams(e.target.checked)}>
|
||||
Add selected users to teams
|
||||
</Checkbox>
|
||||
<Card size="sm" className="mb-4 bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle>Team Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={addToTeamsId}
|
||||
checked={addToTeams}
|
||||
onCheckedChange={(checked) => setAddToTeams(checked === true)}
|
||||
aria-label="Add selected users to teams"
|
||||
/>
|
||||
<label htmlFor={addToTeamsId} className="cursor-pointer text-sm text-foreground">
|
||||
Add selected users to teams
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{addToTeams && (
|
||||
<>
|
||||
<div>
|
||||
<Text strong>Select Teams:</Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select teams to add users to"
|
||||
value={selectedTeams}
|
||||
onChange={setSelectedTeams}
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
options={
|
||||
teams?.map((team) => ({
|
||||
label: team.team_alias || team.team_id,
|
||||
value: team.team_id,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{addToTeams && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor={selectedTeamsId} className="block text-sm font-medium text-foreground">
|
||||
Select Teams:
|
||||
</label>
|
||||
<MultiSelect
|
||||
id={selectedTeamsId}
|
||||
className="mt-2"
|
||||
placeholder="Select teams to add users to"
|
||||
value={selectedTeams}
|
||||
onValueChange={setSelectedTeams}
|
||||
options={
|
||||
teams?.map((team) => ({
|
||||
label: team.team_alias || team.team_id,
|
||||
value: team.team_id,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text strong>Team Budget (Optional):</Text>
|
||||
<InputNumber
|
||||
placeholder="Max budget per user in team"
|
||||
value={teamBudget}
|
||||
onChange={(value) => setTeamBudget(value)}
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
min={0}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
Leave empty for unlimited budget within team limits
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={teamBudgetId} className="block text-sm font-medium text-foreground">
|
||||
Team Budget (Optional):
|
||||
</label>
|
||||
<NumericalInput
|
||||
id={teamBudgetId}
|
||||
className="mt-2"
|
||||
placeholder="Max budget per user in team"
|
||||
value={teamBudget ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setTeamBudget(event.target.value === "" ? null : Number(event.target.value))
|
||||
}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Leave empty for unlimited budget within team limits
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
Users will be added with "user" role by default. All users will be added to each selected
|
||||
team.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Users will be added with "user" role by default. All users will be added to each selected
|
||||
team.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<UserEditView
|
||||
|
|
@ -353,8 +365,10 @@ const BulkEditUserModal: React.FC<BulkEditUserModalProps> = ({
|
|||
/>
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: "center", marginTop: "10px" }}>
|
||||
<Text>Updating {updateAllUsers ? "all users" : selectedUsers.length} user(s)...</Text>
|
||||
<div className="mt-2.5 text-center">
|
||||
<span className="text-sm text-foreground">
|
||||
Updating {updateAllUsers ? "all users" : selectedUsers.length} user(s)...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { CreateUserButton } from "./CreateUserButton";
|
||||
|
|
@ -79,7 +79,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should open the invite modal when invite user button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<CreateUserButton {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument();
|
||||
|
|
@ -107,7 +107,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should close modal when cancel is clicked in standalone mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<CreateUserButton {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -124,7 +124,7 @@ describe("CreateUserButton", () => {
|
|||
|
||||
describe("embedded mode submission", () => {
|
||||
it("should call userCreateCall when form is submitted in embedded mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-123" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-1",
|
||||
|
|
@ -158,7 +158,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should call onUserCreated callback when user is created in embedded mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onUserCreated = vi.fn();
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-456" } });
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should show error notification when user creation fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockRejectedValue({ response: { data: { detail: "Email already exists" } } });
|
||||
|
||||
renderWithProviders(
|
||||
|
|
@ -204,7 +204,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should show info notification when making API call", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-3",
|
||||
|
|
@ -233,7 +233,7 @@ describe("CreateUserButton", () => {
|
|||
|
||||
describe("standalone mode submission", () => {
|
||||
it("should show success notification when user is created successfully in standalone mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "new-user-789" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-2",
|
||||
|
|
@ -262,7 +262,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should show onboarding modal when user is created and SSO is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "sso-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-sso",
|
||||
|
|
@ -302,7 +302,7 @@ describe("CreateUserButton", () => {
|
|||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-org",
|
||||
|
|
@ -349,7 +349,7 @@ describe("CreateUserButton", () => {
|
|||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-nma",
|
||||
|
|
@ -381,7 +381,7 @@ describe("CreateUserButton", () => {
|
|||
|
||||
describe("send invitation email toggle", () => {
|
||||
it("should send send_invite_email true by default in embedded mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "default-on-user" } });
|
||||
|
||||
renderWithProviders(
|
||||
|
|
@ -409,7 +409,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should send send_invite_email false when the checkbox is unchecked in embedded mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "unchecked-user" } });
|
||||
|
||||
renderWithProviders(
|
||||
|
|
@ -438,7 +438,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should send send_invite_email true by default in standalone mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "standalone-default-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-default",
|
||||
|
|
@ -473,7 +473,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should send send_invite_email false when the checkbox is unchecked in standalone mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "standalone-off-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
id: "inv-off",
|
||||
|
|
@ -509,7 +509,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should keep the checkbox checked by default when the modal is opened in standalone mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
|
||||
renderWithProviders(
|
||||
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
|
||||
|
|
@ -539,7 +539,7 @@ describe("CreateUserButton", () => {
|
|||
const submittedPayload = () => mockUserCreateCall.mock.calls[0][2];
|
||||
|
||||
it("should send exactly seven keys from the standalone modal, with the untouched ones undefined", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u1" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({ id: "i1", user_id: "u1", has_user_setup_sso: false } as any);
|
||||
|
||||
|
|
@ -575,7 +575,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should send exactly six keys from the embedded form, with no organization_ids", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u2" } });
|
||||
|
||||
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} isEmbedded />);
|
||||
|
|
@ -607,7 +607,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should not default models to no-default-models for a proxy admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u3" } });
|
||||
|
||||
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} isEmbedded />);
|
||||
|
|
@ -637,7 +637,7 @@ describe("CreateUserButton", () => {
|
|||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u4" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({ id: "i4", user_id: "u4", has_user_setup_sso: false } as any);
|
||||
|
||||
|
|
@ -668,7 +668,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should send metadata as the raw string the user typed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u5" } });
|
||||
|
||||
renderWithProviders(<CreateUserButton {...defaultProps} possibleUIRoles={ROLES} isEmbedded />);
|
||||
|
|
@ -685,7 +685,7 @@ describe("CreateUserButton", () => {
|
|||
expect(submittedPayload().metadata).toBe('{"a":1}');
|
||||
});
|
||||
it("should leave models out entirely for a proxy admin created from the standalone modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "u6" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({ id: "i6", user_id: "u6", has_user_setup_sso: false } as any);
|
||||
|
||||
|
|
@ -711,9 +711,7 @@ describe("CreateUserButton", () => {
|
|||
});
|
||||
|
||||
it("should discard models picked in the personal key section once it is collapsed again", async () => {
|
||||
// antd paints its Select placeholder with pointer-events: none, so the
|
||||
// default check would reject the very click a real user makes on it.
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
mockUserCreateCall.mockResolvedValue({ data: { u: "u7" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({ id: "i7", user_id: "u7", has_user_setup_sso: false } as any);
|
||||
|
||||
|
|
@ -725,11 +723,7 @@ describe("CreateUserButton", () => {
|
|||
await user.click(screen.getByText("User"));
|
||||
|
||||
await user.click(within(dialog).getByText("Personal Key Creation"));
|
||||
// antd Select exposes no accessible name here, the migrated combobox does,
|
||||
// so the same test has to reach the control either way.
|
||||
const modelsSelect =
|
||||
within(dialog).queryByRole("combobox", { name: /select models/i }) ??
|
||||
(await within(dialog).findByText("Select models"));
|
||||
const modelsSelect = within(dialog).getByRole("combobox", { name: /select models/i });
|
||||
await user.click(modelsSelect);
|
||||
await user.click(await screen.findByText("All Proxy Models"));
|
||||
await user.click(within(dialog).getByText("Personal Key Creation"));
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { FormField } from "@/components/shared/form/FormField";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Modal, Typography } from "antd";
|
||||
import { ChevronRight, CircleHelp, Info, UserPlus } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
@ -26,7 +26,7 @@ import {
|
|||
userCreateCall,
|
||||
} from "./networking";
|
||||
import OnboardingModal, { InvitationLink } from "./onboarding_link";
|
||||
const { Link } = Typography;
|
||||
|
||||
// Helper function to generate UUID compatible across all environments
|
||||
const generateUUID = (): string => {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
|
|
@ -140,9 +140,9 @@ const EmailInvitationsNotice: React.FC = () => (
|
|||
<AlertTitle>Email invitations</AlertTitle>
|
||||
<AlertDescription>
|
||||
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.{" "}
|
||||
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
|
||||
<a href="https://docs.litellm.ai/docs/proxy/email" target="_blank" rel="noreferrer">
|
||||
Learn how to set up email notifications
|
||||
</Link>
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -328,110 +328,108 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
|||
<Button type="button" onClick={() => setIsModalVisible(true)}>
|
||||
+ Invite User
|
||||
</Button>
|
||||
<Modal
|
||||
title="Invite User"
|
||||
open={isModalVisible}
|
||||
width={800}
|
||||
footer={null}
|
||||
onOk={() => setIsModalVisible(false)}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="mb-1 text-sm text-foreground">Create a User who can own keys</p>
|
||||
<EmailInvitationsNotice />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleCreate)}>
|
||||
<FieldGroup>
|
||||
{userEmailField}
|
||||
{roleField(
|
||||
labelWithHint(
|
||||
"Global Proxy Role",
|
||||
"This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings",
|
||||
),
|
||||
)}
|
||||
{teamField}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization_ids"
|
||||
label="Organization"
|
||||
description="The user will be added to the selected organization(s)."
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<Select
|
||||
multiple
|
||||
items={organizationOptions}
|
||||
value={value ?? []}
|
||||
onValueChange={(selected: string[]) => onChange(selected.length === 0 ? undefined : selected)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue placeholder="Select Organization">
|
||||
{(selected: string[]) =>
|
||||
selected.length === 0
|
||||
? "Select Organization"
|
||||
: organizationOptions
|
||||
.filter((option) => selected.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
.join(", ")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizationOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="mb-1 text-sm text-foreground">Create a User who can own keys</p>
|
||||
<EmailInvitationsNotice />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleCreate)}>
|
||||
<FieldGroup>
|
||||
{userEmailField}
|
||||
{roleField(
|
||||
labelWithHint(
|
||||
"Global Proxy Role",
|
||||
"This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings",
|
||||
),
|
||||
)}
|
||||
</FormField>
|
||||
{teamField}
|
||||
|
||||
{metadataField}
|
||||
{sendInviteEmailField}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization_ids"
|
||||
label="Organization"
|
||||
description="The user will be added to the selected organization(s)."
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<Select
|
||||
multiple
|
||||
items={organizationOptions}
|
||||
value={value ?? []}
|
||||
onValueChange={(selected: string[]) => onChange(selected.length === 0 ? undefined : selected)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue placeholder="Select Organization">
|
||||
{(selected: string[]) =>
|
||||
selected.length === 0
|
||||
? "Select Organization"
|
||||
: organizationOptions
|
||||
.filter((option) => selected.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
.join(", ")
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizationOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<Collapsible open={isPersonalKeyOpen} onOpenChange={setIsPersonalKeyOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-semibold text-foreground">
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${isPersonalKeyOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
Personal Key Creation
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label={labelWithHint("Models", "Models user has access to, outside of team scope.")}
|
||||
description="Models user has access to, outside of team scope."
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ label: "All Proxy Models", value: "all-proxy-models" },
|
||||
{ label: "No Default Models", value: "no-default-models" },
|
||||
...userModels.map((model) => ({ label: getModelDisplayName(model), value: model })),
|
||||
]}
|
||||
value={value ?? []}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select models"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FieldGroup>
|
||||
{metadataField}
|
||||
{sendInviteEmailField}
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Button type="submit">
|
||||
<UserPlus />
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</Modal>
|
||||
<Collapsible open={isPersonalKeyOpen} onOpenChange={setIsPersonalKeyOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm font-semibold text-foreground">
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${isPersonalKeyOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
Personal Key Creation
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label={labelWithHint("Models", "Models user has access to, outside of team scope.")}
|
||||
description="Models user has access to, outside of team scope."
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<MultiSelect
|
||||
options={[
|
||||
{ label: "All Proxy Models", value: "all-proxy-models" },
|
||||
{ label: "No Default Models", value: "no-default-models" },
|
||||
...userModels.map((model) => ({ label: getModelDisplayName(model), value: model })),
|
||||
]}
|
||||
value={value ?? []}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select models"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Button type="submit">
|
||||
<UserPlus />
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{apiuser && (
|
||||
<OnboardingModal
|
||||
isInvitationLinkModalVisible={isInvitationLinkModalVisible}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Space, Table, Typography } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { getConfigFieldSetting, updateConfigFieldSetting } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { FieldGroup } from "@/components/shared/form/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { pluginSchema, type PluginFormValues } from "./schema";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const INLINE_CODE_CLASS = "rounded-sm bg-muted px-1 py-0.5 font-mono text-xs";
|
||||
|
||||
interface Plugin {
|
||||
name: string;
|
||||
|
|
@ -85,68 +87,96 @@ export default function PluginSettings() {
|
|||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (v: string) => <Text code>{v}</Text>,
|
||||
},
|
||||
{ title: "Display Name", dataIndex: "display_name", key: "display_name" },
|
||||
{
|
||||
title: "URL",
|
||||
dataIndex: "url",
|
||||
key: "url",
|
||||
render: (v: string) => (
|
||||
<a href={v} target="_blank" rel="noopener noreferrer">
|
||||
{v}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Plugin Key",
|
||||
dataIndex: "plugin_key",
|
||||
key: "plugin_key",
|
||||
render: (v?: string) => (v ? <Text code>{"•".repeat(8)}</Text> : <Text type="secondary">—</Text>),
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
render: (_: unknown, plugin: Plugin, idx: number) => (
|
||||
<Space>
|
||||
<Button variant="outline" size="icon-sm" aria-label={`Edit ${plugin.name}`} onClick={() => openEdit(idx)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${plugin.name}`}
|
||||
onClick={() => handleDelete(idx)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
const renderRows = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-6 text-center">
|
||||
<UiLoadingSpinner className="mx-auto size-6 text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (plugins.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
|
||||
No data
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
return plugins.map((plugin, idx) => (
|
||||
<TableRow key={plugin.name}>
|
||||
<TableCell>
|
||||
<code className={INLINE_CODE_CLASS}>{plugin.name}</code>
|
||||
</TableCell>
|
||||
<TableCell>{plugin.display_name}</TableCell>
|
||||
<TableCell>
|
||||
<a href={plugin.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
{plugin.url}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{plugin.plugin_key ? (
|
||||
<code className={INLINE_CODE_CLASS}>{"•".repeat(8)}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon-sm" aria-label={`Edit ${plugin.name}`} onClick={() => openEdit(idx)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${plugin.name}`}
|
||||
onClick={() => handleDelete(idx)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>Plugins</Title>
|
||||
<Paragraph>
|
||||
Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in the
|
||||
top-left of the sidebar.
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
Each plugin must expose <Text code>GET /api/plugin-manifest</Text> returning nav items and capabilities.
|
||||
</Paragraph>
|
||||
<CardHeader>
|
||||
<h4 className="text-base font-semibold text-foreground">Plugins</h4>
|
||||
<p className="text-sm text-foreground">
|
||||
Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in
|
||||
the top-left of the sidebar.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each plugin must expose <code className={INLINE_CODE_CLASS}>GET /api/plugin-manifest</code> returning nav
|
||||
items and capabilities.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="mb-4" onClick={openAdd}>
|
||||
<Plus />
|
||||
Add Plugin
|
||||
</Button>
|
||||
|
||||
<Button className="mb-4" onClick={openAdd}>
|
||||
<Plus />
|
||||
Add Plugin
|
||||
</Button>
|
||||
|
||||
<Table dataSource={plugins} columns={columns} rowKey="name" loading={loading} pagination={false} size="small" />
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Plugin Key</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderRows()}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={(open) => !open && setModalOpen(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -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<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
return false;
|
||||
};
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "your-teams",
|
||||
|
|
@ -611,7 +609,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
];
|
||||
|
||||
return (
|
||||
<Content className={selectedTeamId ? "px-12 py-6" : "p-8"}>
|
||||
<main className={selectedTeamId ? "px-12 py-6" : "p-8"}>
|
||||
{selectedTeamId ? (
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
|
|
@ -631,26 +629,43 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : (
|
||||
<PageHeader
|
||||
icon={<Users />}
|
||||
title="Teams"
|
||||
subtitle="Manage teams, members, and their access to models and budgets"
|
||||
primaryAction={
|
||||
canCreateOrManageTeams(userRole, userID, organizations) ? (
|
||||
<UIButton onClick={() => setIsTeamModalVisible(true)} data-testid="create-team-button">
|
||||
<Plus className="size-4" />
|
||||
Create Team
|
||||
</UIButton>
|
||||
) : undefined
|
||||
}
|
||||
tabs={({ leadingControls }) => (
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{ left: leadingControls }}
|
||||
className="[&>.ant-tabs-nav]:!mb-6 [&>.ant-tabs-nav]:before:!border-b-0 [&_.ant-tabs-ink-bar]:!h-0.5 [&_.ant-tabs-tab]:!py-[7px] [&_.ant-tabs-tab+_.ant-tabs-tab]:!ml-[22px] [&_.ant-tabs-tab-active]:font-semibold"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Tabs defaultValue={tabItems[0].key} className="gap-6">
|
||||
<PageHeader
|
||||
icon={<Users />}
|
||||
title="Teams"
|
||||
subtitle="Manage teams, members, and their access to models and budgets"
|
||||
primaryAction={
|
||||
canCreateOrManageTeams(userRole, userID, organizations) ? (
|
||||
<UIButton onClick={() => setIsTeamModalVisible(true)} data-testid="create-team-button">
|
||||
<Plus className="size-4" />
|
||||
Create Team
|
||||
</UIButton>
|
||||
) : undefined
|
||||
}
|
||||
tabs={({ leadingControls }) => (
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="gap-0 p-0 [&>[data-slot=tabs-trigger]+[data-slot=tabs-trigger]]:ml-[22px]"
|
||||
>
|
||||
{leadingControls}
|
||||
{tabItems.map((item) => (
|
||||
<TabsTrigger
|
||||
key={item.key}
|
||||
value={item.key}
|
||||
className="flex-none px-0 py-[7px] data-active:font-semibold"
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
/>
|
||||
{tabItems.map((item) => (
|
||||
<TabsContent key={item.key} value={item.key}>
|
||||
{item.children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{canCreateOrManageTeams(userRole, userID, organizations) && (
|
||||
|
|
@ -1203,7 +1218,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Content>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AddModelFormProps> = ({
|
||||
form,
|
||||
registry,
|
||||
|
|
@ -117,6 +119,34 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
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: <ProviderLogo provider={providerInfo.provider} className="w-5 h-5" />,
|
||||
})),
|
||||
[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<AddModelFormProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Title level={2}>Add Model</Title>
|
||||
<h2 className="mb-4 text-2xl font-semibold text-foreground">Add Model</h2>
|
||||
|
||||
<Card>
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleOk().then((submitted) => {
|
||||
if (submitted) {
|
||||
setTeamAdminSelectedTeam(null);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{requiresTeamScope && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint("Select Team", "Select the team for which you want to add this model")}
|
||||
name="team_id"
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Please select a team to continue") } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<TeamDropdown
|
||||
value={control.value as string | undefined}
|
||||
onChange={(value) => {
|
||||
control.onChange(value);
|
||||
setTeamAdminSelectedTeam(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
{!teamAdminSelectedTeam && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Team Selection Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
As a team admin, you need to select your team first before adding models.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint("Provider", "E.g. OpenAI, Azure OpenAI, Anthropic, Bedrock, etc.")}
|
||||
name="custom_llm_provider"
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Required") } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<AntdSelect
|
||||
id={control.id}
|
||||
virtual={false}
|
||||
showSearch
|
||||
loading={isProviderMetadataLoading}
|
||||
placeholder={isProviderMetadataLoading ? "Loading providers..." : "Select a provider"}
|
||||
optionFilterProp="data-label"
|
||||
value={control.value as string | undefined}
|
||||
onBlur={control.onBlur}
|
||||
onChange={(value) => {
|
||||
control.onChange(value);
|
||||
setSelectedProvider(value as Providers);
|
||||
setProviderModelsFn(value as Providers);
|
||||
form.setValue("model", []);
|
||||
form.setValue("model_name", undefined);
|
||||
}}
|
||||
>
|
||||
{providerMetadataErrorText && sortedProviderMetadata.length === 0 && (
|
||||
<AntdSelect.Option key="__error" value="">
|
||||
{providerMetadataErrorText}
|
||||
</AntdSelect.Option>
|
||||
)}
|
||||
{sortedProviderMetadata.map((providerInfo) => {
|
||||
const displayName = providerInfo.provider_display_name;
|
||||
const providerKey = providerInfo.provider;
|
||||
|
||||
return (
|
||||
<AntdSelect.Option key={providerKey} value={providerKey} data-label={displayName}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ProviderLogo provider={providerKey} className="w-5 h-5" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
);
|
||||
})}
|
||||
</AntdSelect>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<LiteLLMModelNameField
|
||||
selectedProvider={selectedProvider}
|
||||
providerModels={providerModels}
|
||||
getPlaceholder={getPlaceholder}
|
||||
/>
|
||||
|
||||
{/* Conditionally Render "Public Model Name" */}
|
||||
<ConditionalPublicModelName />
|
||||
|
||||
{/* Select Mode */}
|
||||
<MountedFormField label="Mode" name="mode" className="mb-1">
|
||||
{(control) => (
|
||||
<AntdSelect
|
||||
id={control.id}
|
||||
style={{ width: "100%" }}
|
||||
value={control.value as string | undefined}
|
||||
onBlur={control.onBlur}
|
||||
onChange={(value) => {
|
||||
control.onChange(value);
|
||||
setTestMode(value);
|
||||
}}
|
||||
options={TEST_MODES}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<Row>
|
||||
<Col span={10}></Col>
|
||||
<Col span={10}>
|
||||
<p className="text-sm mb-5 mt-1">
|
||||
<strong>Optional</strong> - LiteLLM endpoint to use when health checking this model{" "}
|
||||
<Link href="https://docs.litellm.ai/docs/proxy/health#health" target="_blank">
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="mb-4">
|
||||
<Typography.Text className="text-sm text-muted-foreground mb-2">
|
||||
Either select existing credentials OR enter new provider credentials below
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<MountedFormField
|
||||
label="Existing Credentials"
|
||||
name="litellm_credential_name"
|
||||
defaultValue={null}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<AntdSelect
|
||||
id={control.id}
|
||||
showSearch
|
||||
placeholder="Select or search for existing credentials"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(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
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
{/* Only show provider specific fields if no credentials selected */}
|
||||
{!selectedCredentialName && (
|
||||
<>
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">OR</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
<ProviderSpecificFields selectedProvider={selectedProvider} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">Additional Model Info Settings</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
{/* Team-only Model Switch - Only show for proxy admins, not team admins */}
|
||||
{(isAdmin || !isTeamAdmin) && (
|
||||
<Field className="mb-4">
|
||||
<FieldLabel>
|
||||
{labelWithHint(
|
||||
"Team-BYOK Model",
|
||||
"Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.",
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Tooltip
|
||||
title={
|
||||
!premiumUser
|
||||
? "This is an enterprise-only feature. Upgrade to premium to restrict model+credential combinations to a specific team."
|
||||
: ""
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<span className="inline-flex">
|
||||
<Switch
|
||||
checked={isTeamOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsTeamOnly(checked);
|
||||
if (!checked) {
|
||||
form.setValue("team_id", undefined);
|
||||
}
|
||||
}}
|
||||
disabled={!premiumUser}
|
||||
aria-label="Team-BYOK Model"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Conditional Team Selection */}
|
||||
{isTeamOnly && !requiresTeamScope && (
|
||||
<CardContent>
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleOk().then((submitted) => {
|
||||
if (submitted) {
|
||||
setTeamAdminSelectedTeam(null);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{requiresTeamScope && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint("Select Team", "Only keys for this team will be able to call this model.")}
|
||||
label={labelWithHint("Select Team", "Select the team for which you want to add this model")}
|
||||
name="team_id"
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Please select a team to continue") } }}
|
||||
className="mb-4"
|
||||
required={isTeamOnly && !isAdmin}
|
||||
rules={
|
||||
isTeamOnly && !isAdmin
|
||||
? { validate: { required: antdRequired("Please select a team.") } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(control) => (
|
||||
<TeamDropdown
|
||||
value={control.value as string | undefined}
|
||||
onChange={control.onChange}
|
||||
disabled={!premiumUser}
|
||||
onChange={(value) => {
|
||||
control.onChange(value);
|
||||
setTeamAdminSelectedTeam(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
{!teamAdminSelectedTeam && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Team Selection Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
As a team admin, you need to select your team first before adding models.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint("Provider", "E.g. OpenAI, Azure OpenAI, Anthropic, Bedrock, etc.")}
|
||||
name="custom_llm_provider"
|
||||
required
|
||||
rules={{ validate: { required: antdRequired("Required") } }}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
options={providerOptions}
|
||||
emptyText={providerMetadataErrorText ?? "No providers found"}
|
||||
placeholder={isProviderMetadataLoading ? "Loading providers..." : "Select a provider"}
|
||||
value={(control.value as string | undefined) ?? ""}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
applyProviderSelection(value as Providers);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<LiteLLMModelNameField
|
||||
selectedProvider={selectedProvider}
|
||||
providerModels={providerModels}
|
||||
getPlaceholder={getPlaceholder}
|
||||
/>
|
||||
|
||||
{/* Conditionally Render "Public Model Name" */}
|
||||
<ConditionalPublicModelName />
|
||||
|
||||
{/* Select Mode */}
|
||||
<MountedFormField label="Mode" name="mode" className="mb-1">
|
||||
{(control) => (
|
||||
<Select
|
||||
items={TEST_MODES}
|
||||
value={(control.value as string | undefined) ?? null}
|
||||
onValueChange={(value: string | null) => {
|
||||
control.onChange(value);
|
||||
setTestMode(value ?? "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={control.id} className="w-full" aria-label="Mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEST_MODES.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<div className="grid grid-cols-12">
|
||||
<div className="col-span-5" />
|
||||
<div className="col-span-5">
|
||||
<p className="text-sm mb-5 mt-1">
|
||||
<strong>Optional</strong> - LiteLLM endpoint to use when health checking this model{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/health#health"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="mb-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Either select existing credentials OR enter new provider credentials below
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<MountedFormField
|
||||
label="Existing Credentials"
|
||||
name="litellm_credential_name"
|
||||
defaultValue={null}
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
options={credentialOptions}
|
||||
value={(control.value as string | null | undefined) ?? ""}
|
||||
onValueChange={(value) => control.onChange(value === "" ? null : value)}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
{/* Only show provider specific fields if no credentials selected */}
|
||||
{!selectedCredentialName && (
|
||||
<>
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">OR</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
<ProviderSpecificFields selectedProvider={selectedProvider} />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center my-4">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">Additional Model Info Settings</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
{/* Team-only Model Switch - Only show for proxy admins, not team admins */}
|
||||
{(isAdmin || !isTeamAdmin) && (
|
||||
<Field className="mb-4">
|
||||
<FieldLabel>
|
||||
{labelWithHint(
|
||||
"Team-BYOK Model",
|
||||
"Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.",
|
||||
)}
|
||||
</FieldLabel>
|
||||
<SimpleTooltip
|
||||
content={
|
||||
!premiumUser
|
||||
? "This is an enterprise-only feature. Upgrade to premium to restrict model+credential combinations to a specific team."
|
||||
: ""
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<span className="inline-flex">
|
||||
<Switch
|
||||
checked={isTeamOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsTeamOnly(checked);
|
||||
if (!checked) {
|
||||
form.setValue("team_id", undefined);
|
||||
}
|
||||
}}
|
||||
disabled={!premiumUser}
|
||||
aria-label="Team-BYOK Model"
|
||||
/>
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Conditional Team Selection */}
|
||||
{isTeamOnly && !requiresTeamScope && (
|
||||
<MountedFormField
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to give users access to select models, and add new ones to the group over time.",
|
||||
"Select Team",
|
||||
"Only keys for this team will be able to call this model.",
|
||||
)}
|
||||
name="model_access_group"
|
||||
name="team_id"
|
||||
className="mb-4"
|
||||
required={isTeamOnly && !isAdmin}
|
||||
rules={
|
||||
isTeamOnly && !isAdmin
|
||||
? { validate: { required: antdRequired("Please select a team.") } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(control) => (
|
||||
<AntdSelect
|
||||
id={control.id}
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[","]}
|
||||
value={control.value as string[] | undefined}
|
||||
<TeamDropdown
|
||||
value={control.value as string | undefined}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
options={modelAccessGroups.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
disabled={!premiumUser}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
)}
|
||||
<AdvancedSettings
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams}
|
||||
guardrailsList={guardrailsList || []}
|
||||
tagsList={tagsList || {}}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
|
||||
</Tooltip>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
aria-busy={isTestingConnection}
|
||||
>
|
||||
Test Connect
|
||||
</Button>
|
||||
<Button data-testid="add-model-btn" type="submit">
|
||||
Add Model
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<MountedFormField
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to give users access to select models, and add new ones to the group over time.",
|
||||
)}
|
||||
name="model_access_group"
|
||||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={control.id}
|
||||
value={control.value as string[] | undefined}
|
||||
onChange={control.onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={control["aria-invalid"] ? true : undefined}
|
||||
ariaDescribedBy={control["aria-describedby"]}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
</>
|
||||
)}
|
||||
<AdvancedSettings
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams}
|
||||
guardrailsList={guardrailsList || []}
|
||||
tagsList={tagsList || {}}
|
||||
accessToken={accessToken || ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<SimpleTooltip content="Get help on our github">
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
aria-busy={isTestingConnection}
|
||||
>
|
||||
Test Connect
|
||||
</Button>
|
||||
<Button data-testid="add-model-btn" type="submit">
|
||||
Add Model
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
</>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test Connection Results Modal */}
|
||||
|
|
|
|||
|
|
@ -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<AddAutoRouterTabProps> = ({
|
|||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auto_router_name"
|
||||
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />}
|
||||
</FormField>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && (
|
||||
<div className="text-xs mt-1 text-green-600 dark:text-green-400">{matchedHint}</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-red-500 dark:text-red-400">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requiresTeamScope && (
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(() => handleAutoRouterSubmit())} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
|
||||
)}
|
||||
name="auto_router_name"
|
||||
label={labelWithHint("Auto Router Name", "Unique name for this auto router configuration")}
|
||||
>
|
||||
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., smart_router, auto_router_1" />}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">Template</label>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={selectedPreset ?? null}
|
||||
onValueChange={(presetKey: string | null) => handlePresetChange(presetKey ?? undefined)}
|
||||
>
|
||||
<SelectTrigger data-testid="template-selector" className="w-full">
|
||||
<SelectValue placeholder="Choose a template or select Custom to define your own" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedPresetOptions.map(({ preset, availability: presetState }) => {
|
||||
const disabledHint = presetDisabledHint(presetState);
|
||||
const hintClass = isPresetHintAlarming(presetState)
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: "text-muted-foreground";
|
||||
const matchedHint =
|
||||
presetState.kind === "available" && presetState.viaDeployments
|
||||
? "Matches your deployments"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={preset.key}
|
||||
value={preset.key}
|
||||
label={preset.label}
|
||||
disabled={disabledHint !== null}
|
||||
title={disabledHint ?? preset.description}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{preset.description}</div>
|
||||
{disabledHint && <div className={`text-xs mt-1 ${hintClass}`}>{disabledHint}</div>}
|
||||
{matchedHint && (
|
||||
<div className="text-xs mt-1 text-green-600 dark:text-green-400">{matchedHint}</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
<SelectItem value="custom" label="Custom Configuration">
|
||||
<div>
|
||||
<div className="font-medium">Custom Configuration</div>
|
||||
<div className="text-xs text-muted-foreground">Define your auto router from scratch</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{modelsUnverifiable && (
|
||||
<div className="text-xs mt-1 text-red-500 dark:text-red-400">
|
||||
Could not load available models.{" "}
|
||||
<button type="button" className="underline" onClick={() => refetchModels()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requiresTeamScope && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Select Team",
|
||||
"Select the team this auto router belongs to. Only keys for this team will be able to call it.",
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig.tiers)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
{({ id, value, onChange }) => <TeamDropdown id={id} value={value} onChange={onChange} />}
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
<div className="border border-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailsExpanded((expanded) => !expanded)}
|
||||
className="w-full flex flex-col gap-1 px-4 py-3 text-left hover:bg-muted"
|
||||
data-testid="detailed-configuration-toggle"
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
{detailsExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
Detailed Configuration
|
||||
</span>
|
||||
{!detailsExpanded && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tierConfigSummary(complexityRouterConfig.tiers)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{detailsExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<ComplexityRouterConfig
|
||||
modelInfo={modelInfo}
|
||||
value={complexityRouterConfig}
|
||||
onChange={setComplexityRouterConfig}
|
||||
customTechnicalKeywords={customTechnicalKeywords}
|
||||
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
|
||||
keywordTierRules={keywordTierRules}
|
||||
onKeywordTierRulesChange={setKeywordTierRules}
|
||||
semanticMatchingEnabled={semanticMatchingEnabled}
|
||||
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
|
||||
embeddingModel={embeddingModel}
|
||||
onEmbeddingModelChange={setEmbeddingModel}
|
||||
matchThreshold={matchThreshold}
|
||||
onMatchThresholdChange={setMatchThreshold}
|
||||
escalationKeywords={escalationKeywords}
|
||||
onEscalationKeywordsChange={setEscalationKeywords}
|
||||
showValidationErrors={showValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_access_group"
|
||||
label={labelWithHint(
|
||||
"Model Access Group",
|
||||
"Use model access groups to control who can access this auto router",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<AccessGroupTagsCombobox
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={modelAccessGroups}
|
||||
ariaInvalid={ariaInvalid}
|
||||
ariaDescribedBy={ariaDescribedBy}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex gap-2">
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex gap-2">
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
Test Routing
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-routing-btn"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => setIsRoutingTestVisible(true)}
|
||||
data-testid="auto-router-test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
Test Routing
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-testid="auto-router-test-connect-btn"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
</Button>
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
<BlockedReasonTooltip reason={submitBlockedReason}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitBlockedReason !== null}
|
||||
onClick={() => {
|
||||
void handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</BlockedReasonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isRoutingTestVisible} onOpenChange={(open) => !open && setIsRoutingTestVisible(false)}>
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
{/* Export Type Selection */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2 block">Export Destination</p>
|
||||
<Select
|
||||
value={exportType}
|
||||
onChange={setExportType}
|
||||
options={exportOptions}
|
||||
className="w-full"
|
||||
size="large"
|
||||
/>
|
||||
<Select items={exportOptions} value={exportType} onValueChange={(value) => value && setExportType(value)}>
|
||||
<SelectTrigger className="w-full" aria-label="Export Destination">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{exportOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* CloudZero Configuration */}
|
||||
|
|
@ -265,7 +270,7 @@ const CloudZeroExportModal: React.FC<CloudZeroExportModalProps> = ({ isOpen, onC
|
|||
<div>
|
||||
{settingsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin size="large" />
|
||||
<UiLoadingSpinner className="size-8" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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<ByokCredentialModalProps> = ({ 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<ByokCredentialModalProps> = ({ server
|
|||
<p className="text-gray-500 mb-6">Enter your {serverDisplayName} API key to authorize this connection.</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-semibold text-gray-800 mb-2">{serverDisplayName} API Key</label>
|
||||
<Input.Password
|
||||
<label htmlFor={apiKeyInputId} className="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{serverDisplayName} API Key
|
||||
</label>
|
||||
<PasswordInput
|
||||
id={apiKeyInputId}
|
||||
placeholder="Enter your API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
size="large"
|
||||
className="rounded-lg"
|
||||
groupClassName="rounded-lg"
|
||||
/>
|
||||
{server.byok_api_key_help_url && (
|
||||
<a
|
||||
|
|
@ -200,7 +204,7 @@ export const ByokCredentialModal: React.FC<ByokCredentialModalProps> = ({ server
|
|||
</svg>
|
||||
<span className="text-sm font-medium text-gray-800">Save key for future use</span>
|
||||
</div>
|
||||
<Switch checked={saveKey} onChange={setSaveKey} />
|
||||
<Switch checked={saveKey} onCheckedChange={setSaveKey} aria-label="Save key for future use" />
|
||||
</div>
|
||||
|
||||
{/* Security note */}
|
||||
|
|
|
|||
|
|
@ -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: <Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />,
|
||||
}));
|
||||
|
||||
interface CredentialModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -122,34 +127,27 @@ export default function CredentialModal({
|
|||
className="mb-4"
|
||||
>
|
||||
{(control) => (
|
||||
<AntdSelect
|
||||
id={control.id}
|
||||
showSearch
|
||||
value={control.value as string | undefined}
|
||||
onBlur={control.onBlur}
|
||||
onChange={(value) => {
|
||||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder="Select a provider"
|
||||
options={providerOptions}
|
||||
value={(control.value as string | undefined) ?? ""}
|
||||
onValueChange={(value) => {
|
||||
control.onChange(value);
|
||||
resetCredentialFormOnProviderChange(formAdapter, value as Providers, setSelectedProvider);
|
||||
}}
|
||||
>
|
||||
{Object.entries(Providers).map(([providerEnum, providerDisplayName]) => (
|
||||
<AntdSelect.Option key={providerEnum} value={providerEnum}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />
|
||||
<span>{providerDisplayName}</span>
|
||||
</div>
|
||||
</AntdSelect.Option>
|
||||
))}
|
||||
</AntdSelect>
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<ProviderSpecificFields selectedProvider={selectedProvider} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Link>
|
||||
</Tooltip>
|
||||
<SimpleTooltip content="Get help on our github">
|
||||
<a href="https://github.com/BerriAI/litellm/issues" className="text-sm text-primary hover:underline">
|
||||
Need Help?
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
|
||||
<div>
|
||||
<Button variant="outline" className="mr-2.5" onClick={closeAndReset}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { Typography } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -59,7 +58,6 @@ export default function OnboardingModal({
|
|||
invitationLinkData,
|
||||
modalType = "invitation",
|
||||
}: OnboardingProps) {
|
||||
const { Paragraph } = Typography;
|
||||
const handleInvitationCancel = () => {
|
||||
setIsInvitationLinkModalVisible(false);
|
||||
};
|
||||
|
|
@ -78,11 +76,11 @@ export default function OnboardingModal({
|
|||
<DialogHeader>
|
||||
<DialogTitle>{modalType === "invitation" ? "Invitation Link" : "Reset Password Link"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Paragraph>
|
||||
<p className="text-sm text-foreground">
|
||||
{modalType === "invitation"
|
||||
? "Copy and send the generated link to onboard this user to the proxy."
|
||||
: "Copy and send the generated link to the user to reset their password."}
|
||||
</Paragraph>
|
||||
</p>
|
||||
<div className="flex justify-between pt-5 pb-2">
|
||||
<p className="text-base">User ID</p>
|
||||
<p className="text-sm">{invitationLinkData?.user_id}</p>
|
||||
|
|
|
|||
|
|
@ -189,13 +189,7 @@ const openModal = async (props: Partial<React.ComponentProps<typeof CreateKey>>
|
|||
return view;
|
||||
};
|
||||
|
||||
const antdSearchInput = (placeholder: HTMLElement): HTMLInputElement => {
|
||||
const input = placeholder.parentElement?.querySelector("input");
|
||||
if (input == null) {
|
||||
throw new Error("no antd search input next to the placeholder");
|
||||
}
|
||||
return input;
|
||||
};
|
||||
const userSearchInput = (): Promise<HTMLElement> => screen.findByPlaceholderText("Type email to search for users");
|
||||
|
||||
const openSection = async (name: RegExp) => {
|
||||
await userEvent.click(await screen.findByRole("button", { name }));
|
||||
|
|
@ -563,11 +557,11 @@ describe("CreateKey", () => {
|
|||
|
||||
it("mounts the user search control only once Another User is chosen", async () => {
|
||||
await openModal();
|
||||
expect(screen.queryByText("Type email to search for users")).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Type email to search for users")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
|
||||
|
||||
expect(await screen.findByText("Type email to search for users")).toBeInTheDocument();
|
||||
expect(await userSearchInput()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Another User option from a non-admin", async () => {
|
||||
|
|
@ -664,7 +658,7 @@ describe("CreateKey", () => {
|
|||
it("prefills models once the available model list arrives", async () => {
|
||||
renderCreateKey({ autoOpenCreate: true, prefillData: { models: ["gpt-4"] } });
|
||||
|
||||
expect(await screen.findByTitle("gpt-4", {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByLabelText("gpt-4", {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores a team the user has no access to", async () => {
|
||||
|
|
@ -711,8 +705,8 @@ describe("CreateKey", () => {
|
|||
await openModal();
|
||||
await userEvent.click(await screen.findByLabelText("Models"));
|
||||
|
||||
expect(await screen.findByTitle("All Proxy Models")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("All Team Models")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "All Proxy Models" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "All Team Models" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers all-team-models but hides all-proxy-models once a team is selected", async () => {
|
||||
|
|
@ -724,8 +718,8 @@ describe("CreateKey", () => {
|
|||
|
||||
await userEvent.click(await screen.findByLabelText("Models"));
|
||||
|
||||
expect(await screen.findByTitle("All Team Models")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("All Proxy Models")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "All Team Models" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "All Proxy Models" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -797,7 +791,7 @@ describe("CreateKey", () => {
|
|||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
|
||||
const search = antdSearchInput(await screen.findByText("Type email to search for users"));
|
||||
const search = await userSearchInput();
|
||||
|
||||
await user.type(search, "ali");
|
||||
expect(vi.mocked(userFilterUICall)).not.toHaveBeenCalled();
|
||||
|
|
@ -824,7 +818,7 @@ describe("CreateKey", () => {
|
|||
|
||||
const user = userEvent.setup();
|
||||
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
|
||||
const search = antdSearchInput(await screen.findByText("Type email to search for users"));
|
||||
const search = await userSearchInput();
|
||||
|
||||
await user.type(search, "ali");
|
||||
await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 });
|
||||
|
|
@ -856,7 +850,7 @@ describe("CreateKey", () => {
|
|||
|
||||
const user = userEvent.setup();
|
||||
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
|
||||
const search = antdSearchInput(await screen.findByText("Type email to search for users"));
|
||||
const search = await userSearchInput();
|
||||
|
||||
await user.type(search, "ali");
|
||||
await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 });
|
||||
|
|
@ -884,7 +878,7 @@ describe("CreateKey", () => {
|
|||
|
||||
const user = userEvent.setup();
|
||||
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
|
||||
const search = antdSearchInput(await screen.findByText("Type email to search for users"));
|
||||
const search = await userSearchInput();
|
||||
|
||||
await user.type(search, "ali");
|
||||
await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 });
|
||||
|
|
@ -919,7 +913,7 @@ describe("CreateKey", () => {
|
|||
|
||||
const user = userEvent.setup();
|
||||
renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
|
||||
const search = antdSearchInput(await screen.findByText("Type email to search for users"));
|
||||
const search = await userSearchInput();
|
||||
|
||||
await user.type(search, "ali");
|
||||
await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 });
|
||||
|
|
@ -1007,7 +1001,7 @@ describe("CreateKey", () => {
|
|||
await nameTheKey();
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Key Type"));
|
||||
await userEvent.click(await screen.findByText("Management"));
|
||||
await userEvent.click(await screen.findByRole("option", { name: /^Management/ }));
|
||||
await submit();
|
||||
|
||||
const payload = await createdPayload();
|
||||
|
|
@ -1050,8 +1044,8 @@ describe("CreateKey", () => {
|
|||
await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
|
||||
await nameTheKey();
|
||||
|
||||
await userEvent.type(antdSearchInput(await screen.findByText("Type email to search for users")), "alice");
|
||||
await userEvent.click(await screen.findByText("alice@example.com (u-77)"));
|
||||
await userEvent.type(await userSearchInput(), "alice");
|
||||
await userEvent.click(await screen.findByRole("option", { name: "alice@example.com (u-77)" }));
|
||||
await submit();
|
||||
|
||||
expect((await createdPayload()).user_id).toBe("u-77");
|
||||
|
|
|
|||
|
|
@ -12,7 +12,23 @@ import { Button } from "@/components/ui/button";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldLabel } from "@/components/shared/form/field";
|
||||
import { Input as AntdInput, Radio, Select, Switch, Tag, Tooltip, Typography } from "antd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
|
||||
import { ChevronDown, Info } from "lucide-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
|
@ -35,7 +51,10 @@ import {
|
|||
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
|
||||
import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings";
|
||||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion";
|
||||
import RouterSettingsAccordion, {
|
||||
RouterSettingsAccordionRef,
|
||||
RouterSettingsAccordionValue,
|
||||
} from "../common_components/RouterSettingsAccordion";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import OrganizationDropdown from "../common_components/OrganizationDropdown";
|
||||
import ProjectDropdown from "../common_components/ProjectDropdown";
|
||||
|
|
@ -72,7 +91,13 @@ import { buildKeyCreatePayload, type KeyCreateInput } from "./createKeyPayload";
|
|||
import { simplifyKeyGenerateError } from "./utils";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const { Option } = Select;
|
||||
const KEY_TYPE_OPTIONS = [
|
||||
{ value: "llm_api", label: "AI APIs", hint: "Can call only AI API routes (chat/completions, embeddings, etc.)" },
|
||||
{ value: "management", label: "Management", hint: "Can call only management routes (user/team/key management)" },
|
||||
{ value: "default", label: "Full Access", hint: "Can call all routes (AI APIs, Management, and read-only)" },
|
||||
];
|
||||
|
||||
const KEY_OWNER_LABEL_CLASS = "flex items-center gap-2 text-sm font-normal text-foreground";
|
||||
|
||||
const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-4 py-3 text-left";
|
||||
const SECTION_CHEVRON_CLASS =
|
||||
|
|
@ -253,6 +278,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(false);
|
||||
const [rotationInterval, setRotationInterval] = useState<string>("30d");
|
||||
const [routerSettings, setRouterSettings] = useState<RouterSettingsAccordionValue | null>(null);
|
||||
const routerSettingsRef = useRef<RouterSettingsAccordionRef>(null);
|
||||
const [budgetLimits, setBudgetLimits] = useState<BudgetWindowEntry[]>([]);
|
||||
const [tagRateLimits, setTagRateLimits] = useState<TagRateLimitEntry[]>([]);
|
||||
const [budgetFallbacks, setBudgetFallbacks] = useState<Record<string, string[]>>({});
|
||||
|
|
@ -421,7 +447,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
autoRotationEnabled,
|
||||
rotationInterval,
|
||||
modelAliases,
|
||||
routerSettings,
|
||||
routerSettings: routerSettingsRef.current?.getValue() ?? routerSettings,
|
||||
budgetLimits,
|
||||
tagRateLimits,
|
||||
budgetFallbacks,
|
||||
|
|
@ -469,7 +495,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(() => handleCreate(projectMountedValues(registry, form.getValues)));
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) =>
|
||||
void form.handleSubmit(() => handleCreate(projectMountedValues(registry, form.getValues)))(event);
|
||||
|
||||
// Fetch available models when team or auth changes.
|
||||
// Note: Model prefill from URL params is handled by the useEffect below, which
|
||||
|
|
@ -575,11 +602,6 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
|
||||
const handleUserSearch = useDebouncedCallback((text: string) => fetchUsers(text), { wait: DEBOUNCE_WAIT_MS });
|
||||
|
||||
const handleUserSelect = (_value: string, option: UserOption): void => {
|
||||
const selectedUser = option.user;
|
||||
form.setValue("user_id", selectedUser.user_id);
|
||||
};
|
||||
|
||||
const changeOrganization = (write: FieldWrite) => (orgId: string) => {
|
||||
write(orgId);
|
||||
setSelectedOrganizationId(orgId || null);
|
||||
|
|
@ -615,6 +637,20 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
setSelectedProjectId(projectId);
|
||||
};
|
||||
|
||||
const modelOptions: MultiSelectOption[] = [
|
||||
...(selectedProjectId === null && selectedCreateKeyTeam
|
||||
? [{ value: "all-team-models", label: "All Team Models" }]
|
||||
: []),
|
||||
...(selectedProjectId === null && !selectedCreateKeyTeam
|
||||
? [{ value: "all-proxy-models", label: "All Proxy Models" }]
|
||||
: []),
|
||||
...modelsToPick.map((model) => ({
|
||||
value: model,
|
||||
label: getModelDisplayName(model),
|
||||
disabled: hasAllModelsSentinel(selectedModels),
|
||||
})),
|
||||
];
|
||||
|
||||
const changeKeyType = (write: FieldWrite) => (value: string) => {
|
||||
write(value);
|
||||
setKeyType(value);
|
||||
|
|
@ -645,19 +681,35 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<FieldLabel>
|
||||
<span>
|
||||
Owned By{" "}
|
||||
<Tooltip title="Select who will own this Virtual Key">
|
||||
<SimpleTooltip content="Select who will own this Virtual Key">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<Radio.Group onChange={(e) => setKeyOwner(e.target.value)} value={keyOwner}>
|
||||
<Radio value="you">You</Radio>
|
||||
<Radio value="service_account">Service Account</Radio>
|
||||
{userRole === "Admin" && <Radio value="another_user">Another User</Radio>}
|
||||
<Radio value="agent">
|
||||
Agent <Tag color="purple">New</Tag>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
<RadioGroup
|
||||
className="flex flex-wrap items-center gap-4"
|
||||
value={keyOwner}
|
||||
onValueChange={(value: unknown) => setKeyOwner(String(value))}
|
||||
>
|
||||
<label className={KEY_OWNER_LABEL_CLASS}>
|
||||
<RadioGroupItem value="you" />
|
||||
You
|
||||
</label>
|
||||
<label className={KEY_OWNER_LABEL_CLASS}>
|
||||
<RadioGroupItem value="service_account" />
|
||||
Service Account
|
||||
</label>
|
||||
{userRole === "Admin" && (
|
||||
<label className={KEY_OWNER_LABEL_CLASS}>
|
||||
<RadioGroupItem value="another_user" />
|
||||
Another User
|
||||
</label>
|
||||
)}
|
||||
<label className={KEY_OWNER_LABEL_CLASS}>
|
||||
<RadioGroupItem value="agent" />
|
||||
Agent <Badge>New</Badge>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
{keyOwner === "another_user" && (
|
||||
|
|
@ -665,9 +717,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
User ID{" "}
|
||||
<Tooltip title="The user who will own this key and be responsible for its usage">
|
||||
<SimpleTooltip content="The user who will own this key and be responsible for its usage">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="user_id"
|
||||
|
|
@ -679,26 +731,38 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
)}
|
||||
>
|
||||
{(control) => (
|
||||
<div
|
||||
id={control.id}
|
||||
aria-required={control["aria-required"]}
|
||||
aria-invalid={control["aria-invalid"]}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
onChange={control.onChange}
|
||||
>
|
||||
<div style={{ display: "flex", marginBottom: "8px" }}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Type email to search for users"
|
||||
filterOption={false}
|
||||
onSearch={handleUserSearch}
|
||||
onSelect={(value, option) => handleUserSelect(value, option as UserOption)}
|
||||
options={userOptions}
|
||||
loading={userSearchLoading}
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
notFoundContent={userSearchLoading ? "Searching..." : "No users found"}
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 flex">
|
||||
<Combobox
|
||||
items={userOptions}
|
||||
value={userOptions.find((option) => option.value === control.value) ?? null}
|
||||
filter={null}
|
||||
onValueChange={(option: UserOption | null) => control.onChange(option?.value)}
|
||||
onInputValueChange={handleUserSearch}
|
||||
isItemEqualToValue={(a: UserOption, b: UserOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: UserOption) => option.label}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={control.id}
|
||||
className="w-full"
|
||||
placeholder="Type email to search for users"
|
||||
aria-required={control["aria-required"]}
|
||||
aria-invalid={control["aria-invalid"]}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
showClear={control.value != null && control.value !== ""}
|
||||
onBlur={control.onBlur}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{userSearchLoading ? "Searching..." : "No users found"}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: UserOption) => (
|
||||
<ComboboxItem key={option.value} value={option} title={option.label}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<Button variant="outline" className="ml-2" onClick={() => setIsCreateUserModalVisible(true)}>
|
||||
Create User
|
||||
</Button>
|
||||
|
|
@ -711,19 +775,16 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
{keyOwner === "agent" && (
|
||||
<div className="mt-4 p-4 bg-purple-50 border border-purple-200 rounded-md">
|
||||
<div className="mb-3">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
<label htmlFor="create-key-agent" className="text-sm font-medium text-foreground">
|
||||
Select Agent <span className="text-destructive">*</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<Select
|
||||
showSearch
|
||||
<SearchSelect
|
||||
inputId="create-key-agent"
|
||||
placeholder="Select an agent"
|
||||
style={{ width: "100%" }}
|
||||
value={selectedAgentId}
|
||||
onChange={(value) => setSelectedAgentId(value)}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
emptyText="No agents found"
|
||||
value={selectedAgentId ?? undefined}
|
||||
onValueChange={(value) => setSelectedAgentId(value === "" ? null : value)}
|
||||
options={agentsList.map((a) => ({
|
||||
label: a.agent_name || a.agent_id,
|
||||
value: a.agent_id,
|
||||
|
|
@ -738,9 +799,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Organization{" "}
|
||||
<Tooltip title="The organization this key belongs to. Selecting an organization filters the available teams.">
|
||||
<SimpleTooltip content="The organization this key belongs to. Selecting an organization filters the available teams.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="organization_id"
|
||||
|
|
@ -761,9 +822,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Team{" "}
|
||||
<Tooltip title="The team this key belongs to, which determines available models and budget limits">
|
||||
<SimpleTooltip content="The team this key belongs to, which determines available models and budget limits">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="team_id"
|
||||
|
|
@ -788,9 +849,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Project{" "}
|
||||
<Tooltip title="Assign this key to a project. Selecting a project will lock the team to the project's team.">
|
||||
<SimpleTooltip content="Assign this key to a project. Selecting a project will lock the team to the project's team.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="project_id"
|
||||
|
|
@ -828,15 +889,15 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
{keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "}
|
||||
<Tooltip
|
||||
title={
|
||||
<SimpleTooltip
|
||||
content={
|
||||
keyOwner === "you" || keyOwner === "another_user"
|
||||
? "A descriptive name to identify this key"
|
||||
: "Unique identifier for this service account"
|
||||
}
|
||||
>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="key_alias"
|
||||
|
|
@ -854,9 +915,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Models{" "}
|
||||
<Tooltip title="Select which models this key can access. Choose 'All Team Models' to grant access to all models available to the team. Leave empty to allow access to all models.">
|
||||
<SimpleTooltip content="Select which models this key can access. Choose 'All Team Models' to grant access to all models available to the team. Leave empty to allow access to all models.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="models"
|
||||
|
|
@ -868,14 +929,13 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
className="mt-4"
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
value={control.value as string[] | undefined}
|
||||
mode="multiple"
|
||||
<MultiSelect
|
||||
id={control.id}
|
||||
options={modelOptions}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
placeholder="Select models"
|
||||
style={{ width: "100%" }}
|
||||
disabled={keyType === "management" || keyType === "read_only"}
|
||||
onChange={(values: string[]) => {
|
||||
onValueChange={(values) => {
|
||||
control.onChange(values);
|
||||
if (values.includes("all-team-models")) {
|
||||
form.setValue("models", ["all-team-models"]);
|
||||
|
|
@ -883,23 +943,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
form.setValue("models", ["all-proxy-models"]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!selectedProjectId && selectedCreateKeyTeam && (
|
||||
<Option key="all-team-models" value="all-team-models">
|
||||
All Team Models
|
||||
</Option>
|
||||
)}
|
||||
{!selectedProjectId && !selectedCreateKeyTeam && (
|
||||
<Option key="all-proxy-models" value="all-proxy-models">
|
||||
All Proxy Models
|
||||
</Option>
|
||||
)}
|
||||
{modelsToPick.map((model: string) => (
|
||||
<Option key={model} value={model} disabled={hasAllModelsSentinel(selectedModels)}>
|
||||
{getModelDisplayName(model)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
|
|
@ -907,9 +951,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Key Type{" "}
|
||||
<Tooltip title="Select the type of key to determine what routes and operations this key can access">
|
||||
<SimpleTooltip content="Select the type of key to determine what routes and operations this key can access">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="key_type"
|
||||
|
|
@ -917,37 +961,30 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
items={KEY_TYPE_OPTIONS}
|
||||
value={control.value as string | undefined}
|
||||
placeholder="Select key type"
|
||||
style={{ width: "100%" }}
|
||||
optionLabelProp="label"
|
||||
onChange={changeKeyType(control.onChange)}
|
||||
onValueChange={(value: string | null) =>
|
||||
value != null && changeKeyType(control.onChange)(value)
|
||||
}
|
||||
>
|
||||
<Option value="llm_api" label="AI APIs">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<Typography.Text strong>AI APIs</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 11, margin: "2px 0 0" }}>
|
||||
Can call only AI API routes (chat/completions, embeddings, etc.)
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="management" label="Management">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<Typography.Text strong>Management</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 11, margin: "2px 0 0" }}>
|
||||
Can call only management routes (user/team/key management)
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="default" label="Full Access">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<Typography.Text strong>Full Access</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 11, margin: "2px 0 0" }}>
|
||||
Can call all routes (AI APIs, Management, and read-only)
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</Option>
|
||||
<SelectTrigger
|
||||
id={control.id}
|
||||
className="w-full"
|
||||
aria-invalid={control["aria-invalid"]}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
>
|
||||
<SelectValue placeholder="Select key type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KEY_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="py-1">
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{option.hint}</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
@ -970,9 +1007,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Max Budget (USD){" "}
|
||||
<Tooltip title="Maximum amount in USD this key can spend. When reached, the key will be blocked from making further requests">
|
||||
<SimpleTooltip content="Maximum amount in USD this key can spend. When reached, the key will be blocked from making further requests">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="max_budget"
|
||||
|
|
@ -997,9 +1034,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Reset Budget{" "}
|
||||
<Tooltip title="How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours">
|
||||
<SimpleTooltip content="How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="budget_duration"
|
||||
|
|
@ -1019,9 +1056,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<FieldLabel>
|
||||
<span>
|
||||
Budget Windows{" "}
|
||||
<Tooltip title="Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.">
|
||||
<SimpleTooltip content="Set multiple independent budget windows (e.g., hourly $10 AND monthly $200). Each window tracks spend separately and resets on its own schedule.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<BudgetWindowsEditor value={budgetLimits} onChange={setBudgetLimits} />
|
||||
|
|
@ -1030,9 +1067,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<FieldLabel>
|
||||
<span>
|
||||
Budget Fallbacks{" "}
|
||||
<Tooltip title="When a model exceeds its per-model budget (model_max_budget), requests automatically reroute to fallback models instead of failing. Configure per-model budgets in Advanced Settings.">
|
||||
<SimpleTooltip content="When a model exceeds its per-model budget (model_max_budget), requests automatically reroute to fallback models instead of failing. Configure per-model budgets in Advanced Settings.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<BudgetFallbacksEditor
|
||||
|
|
@ -1047,9 +1084,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Tokens per minute Limit (TPM){" "}
|
||||
<Tooltip title="Maximum number of tokens this key can process per minute. Helps control usage and costs">
|
||||
<SimpleTooltip content="Maximum number of tokens this key can process per minute. Helps control usage and costs">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="tpm_limit"
|
||||
|
|
@ -1088,9 +1125,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Requests per minute Limit (RPM){" "}
|
||||
<Tooltip title="Maximum number of API requests this key can make per minute. Helps prevent abuse and manage load">
|
||||
<SimpleTooltip content="Maximum number of API requests this key can make per minute. Helps prevent abuse and manage load">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="rpm_limit"
|
||||
|
|
@ -1128,9 +1165,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<FieldLabel>
|
||||
<span>
|
||||
Per-Tag Rate Limits{" "}
|
||||
<Tooltip title="Scope rate limits to a request tag so each tag (e.g. a cell or group) gets its own RPM counter. Requests without a matching tag fall back to the key-level limit.">
|
||||
<SimpleTooltip content="Scope rate limits to a request tag so each tag (e.g. a cell or group) gets its own RPM counter. Requests without a matching tag fall back to the key-level limit.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<TagRateLimitEditor value={tagRateLimits} onChange={setTagRateLimits} />
|
||||
|
|
@ -1140,9 +1177,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Throttle on budget exceeded{" "}
|
||||
<Tooltip title="When this key exceeds its max budget, throttle its TPM/RPM to the globally configured percentage instead of blocking access entirely. Requires budget_exceeded_throttle_percentage in litellm_settings and a TPM/RPM limit on the key.">
|
||||
<SimpleTooltip content="When this key exceeds its max budget, throttle its TPM/RPM to the globally configured percentage instead of blocking access entirely. Requires budget_exceeded_throttle_percentage in litellm_settings and a TPM/RPM limit on the key.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="throttle_on_budget_exceeded"
|
||||
|
|
@ -1150,10 +1187,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
{(control) => (
|
||||
<Switch
|
||||
id={control.id}
|
||||
checked={control.value as boolean | undefined}
|
||||
onChange={control.onChange}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
checked={control.value === true}
|
||||
onCheckedChange={control.onChange}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
@ -1162,9 +1198,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Enable Prompt Caching{" "}
|
||||
<Tooltip title="Automatically add prompt caching breakpoints (cache_control markers) to requests made with this key, cutting input cost on repeated prompts. Applies to Anthropic and Bedrock Claude models; requests that already set their own cache_control markers are left untouched.">
|
||||
<SimpleTooltip content="Automatically add prompt caching breakpoints (cache_control markers) to requests made with this key, cutting input cost on repeated prompts. Applies to Anthropic and Bedrock Claude models; requests that already set their own cache_control markers are left untouched.">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="enable_prompt_caching"
|
||||
|
|
@ -1172,10 +1208,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
{(control) => (
|
||||
<Switch
|
||||
id={control.id}
|
||||
checked={control.value as boolean | undefined}
|
||||
onChange={control.onChange}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
checked={control.value === true}
|
||||
onCheckedChange={control.onChange}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
@ -1183,7 +1218,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Guardrails{" "}
|
||||
<Tooltip title="Apply safety guardrails to this key to filter content or enforce policies">
|
||||
<SimpleTooltip content="Apply safety guardrails to this key to filter content or enforce policies">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
|
|
@ -1192,7 +1227,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="guardrails"
|
||||
|
|
@ -1204,11 +1239,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
value={control.value as string[] | undefined}
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
<TagsInput
|
||||
id={control.id}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={control.onChange}
|
||||
disabled={!canEditGuardrails}
|
||||
placeholder={
|
||||
!canEditGuardrails
|
||||
|
|
@ -1223,7 +1257,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Disable Global Guardrails{" "}
|
||||
<Tooltip title="When enabled, this key will bypass any guardrails configured to run on every request (global guardrails)">
|
||||
<SimpleTooltip content="When enabled, this key will bypass any guardrails configured to run on every request (global guardrails)">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
|
|
@ -1232,7 +1266,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="disable_global_guardrails"
|
||||
|
|
@ -1246,11 +1280,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
{(control) => (
|
||||
<Switch
|
||||
id={control.id}
|
||||
checked={control.value as boolean | undefined}
|
||||
onChange={control.onChange}
|
||||
checked={control.value === true}
|
||||
onCheckedChange={control.onChange}
|
||||
disabled={!canEditGuardrails}
|
||||
checkedChildren="Yes"
|
||||
unCheckedChildren="No"
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
|
@ -1259,7 +1292,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Policies{" "}
|
||||
<Tooltip title="Apply policies to this key to control guardrails and other settings">
|
||||
<SimpleTooltip content="Apply policies to this key to control guardrails and other settings">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
|
||||
target="_blank"
|
||||
|
|
@ -1268,7 +1301,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="policies"
|
||||
|
|
@ -1280,11 +1313,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
value={control.value as string[] | undefined}
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
<TagsInput
|
||||
id={control.id}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={control.onChange}
|
||||
disabled={!premiumUser}
|
||||
placeholder={
|
||||
!premiumUser
|
||||
|
|
@ -1301,7 +1333,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Prompts{" "}
|
||||
<Tooltip title="Allow this key to use specific prompt templates">
|
||||
<SimpleTooltip content="Allow this key to use specific prompt templates">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/prompt_management"
|
||||
target="_blank"
|
||||
|
|
@ -1310,7 +1342,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="prompts"
|
||||
|
|
@ -1322,11 +1354,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
value={control.value as string[] | undefined}
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
<TagsInput
|
||||
id={control.id}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={control.onChange}
|
||||
disabled={!premiumUser}
|
||||
placeholder={
|
||||
!premiumUser
|
||||
|
|
@ -1342,9 +1373,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Access Groups{" "}
|
||||
<Tooltip title="Assign access groups to this key. Access groups control which models, MCP servers, and agents this key can use">
|
||||
<SimpleTooltip content="Assign access groups to this key. Access groups control which models, MCP servers, and agents this key can use">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="access_group_ids"
|
||||
|
|
@ -1363,7 +1394,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Allowed Pass Through Routes{" "}
|
||||
<Tooltip title="Allow this key to use specific pass through routes">
|
||||
<SimpleTooltip content="Allow this key to use specific pass through routes">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/pass_through"
|
||||
target="_blank"
|
||||
|
|
@ -1372,7 +1403,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_passthrough_routes"
|
||||
|
|
@ -1402,9 +1433,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Allowed Vector Stores{" "}
|
||||
<Tooltip title="Select which vector stores this key can access. If none selected, the key will have access to all available vector stores">
|
||||
<SimpleTooltip content="Select which vector stores this key can access. If none selected, the key will have access to all available vector stores">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_vector_store_ids"
|
||||
|
|
@ -1424,18 +1455,18 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Metadata{" "}
|
||||
<Tooltip title="JSON object with additional information about this key. Used for tracking or custom logic">
|
||||
<SimpleTooltip content="JSON object with additional information about this key. Used for tracking or custom logic">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="metadata"
|
||||
className="mt-4"
|
||||
>
|
||||
{(control) => (
|
||||
<AntdInput.TextArea
|
||||
<Textarea
|
||||
{...control}
|
||||
value={control.value as string | undefined}
|
||||
value={(control.value as string | undefined) ?? ""}
|
||||
rows={4}
|
||||
placeholder="Enter metadata as JSON"
|
||||
/>
|
||||
|
|
@ -1445,9 +1476,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Tags{" "}
|
||||
<Tooltip title="Tags for tracking spend and/or doing tag-based routing. Used for analytics and filtering">
|
||||
<SimpleTooltip content="Tags for tracking spend and/or doing tag-based routing. Used for analytics and filtering">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="tags"
|
||||
|
|
@ -1455,11 +1486,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
help={`Tags for tracking spend and/or doing tag-based routing.`}
|
||||
>
|
||||
{(control) => (
|
||||
<Select
|
||||
{...control}
|
||||
value={control.value as string[] | undefined}
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
<TagsInput
|
||||
id={control.id}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={control.onChange}
|
||||
placeholder="Select or enter tags"
|
||||
tokenSeparators={[","]}
|
||||
options={tagOptions}
|
||||
|
|
@ -1476,9 +1506,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers or access groups this key can access">
|
||||
<SimpleTooltip content="Select which MCP servers or access groups this key can access">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
|
|
@ -1519,9 +1549,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
label={
|
||||
<span>
|
||||
Allowed Agents{" "}
|
||||
<Tooltip title="Select which agents or access groups this key can access">
|
||||
<SimpleTooltip content="Select which agents or access groups this key can access">
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_agents_and_groups"
|
||||
|
|
@ -1558,8 +1588,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
<SimpleTooltip
|
||||
className="w-full"
|
||||
content={
|
||||
<span>
|
||||
Key-level logging settings is an enterprise feature, get in touch -
|
||||
<a href="https://www.litellm.ai/enterprise" target="_blank">
|
||||
|
|
@ -1567,7 +1598,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</a>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
side="top"
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ opacity: 0.5 }}>
|
||||
|
|
@ -1591,7 +1622,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
</div>
|
||||
<div style={{ position: "absolute", inset: 0, cursor: "not-allowed" }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
|
||||
<Collapsible
|
||||
|
|
@ -1606,6 +1637,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<div className="mt-4 w-full">
|
||||
<RouterSettingsAccordion
|
||||
key={routerSettingsKey}
|
||||
ref={routerSettingsRef}
|
||||
accessToken={accessToken || ""}
|
||||
value={routerSettings || undefined}
|
||||
onChange={setRouterSettings}
|
||||
|
|
@ -1668,8 +1700,8 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<CollapsibleTrigger className={SECTION_HEADER_CLASS}>
|
||||
<div className="flex items-center gap-2">
|
||||
<b>Advanced Settings</b>
|
||||
<Tooltip
|
||||
title={
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<span>
|
||||
Learn more about advanced settings in our{" "}
|
||||
<a
|
||||
|
|
@ -1688,7 +1720,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
}
|
||||
>
|
||||
<Info className="size-4 text-muted-foreground hover:text-foreground cursor-help" />
|
||||
</Tooltip>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<ChevronDown className={SECTION_CHEVRON_CLASS} />
|
||||
</CollapsibleTrigger>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Card, Flex, Input, Space, Typography } from "antd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, RefreshCw, Search } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Plus, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useRoutingGroups, useSaveRoutingGroups } from "@/app/(dashboard)/hooks/routingGroups/useRoutingGroups";
|
||||
import { useRouterFields } from "@/app/(dashboard)/hooks/router/useRouterFields";
|
||||
import { useModelHub } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
|
|
@ -15,8 +16,6 @@ import { toast } from "@/lib/toast";
|
|||
import type { RoutingGroup } from "./types";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const RoutingGroups: React.FC = () => {
|
||||
const { data, isLoading, refetch, isFetching } = useRoutingGroups();
|
||||
const { data: routerFields } = useRouterFields();
|
||||
|
|
@ -102,44 +101,55 @@ const RoutingGroups: React.FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="w-full">
|
||||
<Card bodyStyle={{ padding: 16 }}>
|
||||
<Flex justify="space-between" align="center" gap={12} className="mb-4">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search className="size-4 text-gray-400" />}
|
||||
placeholder="Search groups..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Flex align="center" gap={12}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching && !isLoading}
|
||||
aria-busy={isFetching && !isLoading}
|
||||
>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus />
|
||||
Create Group
|
||||
</Button>
|
||||
<Text type="secondary" className="text-sm whitespace-nowrap">
|
||||
Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Card size="sm">
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<InputGroup className="max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search groups..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchQuery("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching && !isLoading}
|
||||
aria-busy={isFetching && !isLoading}
|
||||
>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus />
|
||||
Create Group
|
||||
</Button>
|
||||
<span className="text-sm whitespace-nowrap text-muted-foreground">
|
||||
Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoutingGroupsTable
|
||||
groups={filteredGroups}
|
||||
isLoading={isLoading}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setDeletingGroup(g)}
|
||||
proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""}
|
||||
/>
|
||||
<RoutingGroupsTable
|
||||
groups={filteredGroups}
|
||||
isLoading={isLoading}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setDeletingGroup(g)}
|
||||
proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RoutingGroupModal
|
||||
|
|
@ -160,10 +170,10 @@ const RoutingGroups: React.FC = () => {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Delete routing group?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Text>
|
||||
Models in <Text strong>{deletingGroup?.group_name}</Text> will fall back to the proxy's top-level
|
||||
routing strategy. This cannot be undone.
|
||||
</Text>
|
||||
<p className="text-sm text-foreground">
|
||||
Models in <span className="font-medium">{deletingGroup?.group_name}</span> will fall back to the
|
||||
proxy's top-level routing strategy. This cannot be undone.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeletingGroup(null)}>
|
||||
Cancel
|
||||
|
|
@ -179,7 +189,7 @@ const RoutingGroups: React.FC = () => {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Update API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Text className="block mb-4 text-muted-foreground">
|
||||
<span className="block mb-4 text-sm text-muted-foreground">
|
||||
Update this model's API key. Only the new key is sent; the rest of the deployment configuration is left
|
||||
untouched.
|
||||
</Text>
|
||||
</span>
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue