diff --git a/docs/my-website/docs/secret_managers/overview.md b/docs/my-website/docs/secret_managers/overview.md index fa1e82b1d0..957e7dc0a0 100644 --- a/docs/my-website/docs/secret_managers/overview.md +++ b/docs/my-website/docs/secret_managers/overview.md @@ -1,3 +1,5 @@ +import Image from '@theme/IdealImage'; + # Secret Managers Overview :::info @@ -45,3 +47,11 @@ general_settings: primary_secret_name: "litellm_secrets" # OPTIONAL. Read multiple keys from one JSON secret on AWS Secret Manager ``` +## Team-Level Secret Manager Settings + +From the **Teams** page in the LiteLLM dashboard you can configure a secret manager per team. Open the team (or the “Create New Team” modal), find the **Secret Manager Settings** panel, and enter the provider-specific JSON configuration (e.g. `{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}`). This configuration is applied whenever LiteLLM writes secrets (e.g., storing virtual keys) on behalf of that team. + + + + +Refer to each provider’s documentation (AWS, Azure, Google, Hashicorp, etc.) for the supported keys/values you can place inside `secret_manager_settings`. diff --git a/docs/my-website/img/secret_manager_settings.png b/docs/my-website/img/secret_manager_settings.png new file mode 100644 index 0000000000..ce13a60ee9 Binary files /dev/null and b/docs/my-website/img/secret_manager_settings.png differ diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6b646086d5..8f9929597f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1393,6 +1393,7 @@ class NewTeamRequest(TeamBase): prompts: Optional[List[str]] = None object_permission: Optional[LiteLLM_ObjectPermissionBase] = None allowed_passthrough_routes: Optional[list] = None + secret_manager_settings: Optional[dict] = None model_rpm_limit: Optional[Dict[str, int]] = None rpm_limit_type: Optional[ Literal["guaranteed_throughput", "best_effort_throughput"] @@ -1459,6 +1460,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase): team_member_tpm_limit: Optional[int] = None team_member_key_duration: Optional[str] = None allowed_passthrough_routes: Optional[list] = None + secret_manager_settings: Optional[dict] = None model_rpm_limit: Optional[Dict[str, int]] = None model_tpm_limit: Optional[Dict[str, int]] = None allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None @@ -3349,6 +3351,7 @@ LiteLLM_ManagementEndpoint_MetadataFields_Premium = [ "team_member_key_duration", "prompts", "logging", + "secret_manager_settings", "allowed_passthrough_routes", ] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index ffb92d1897..4b71554ce2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -571,6 +571,7 @@ const ModelsAndEndpointsView: React.FC = ({ userModels={all_models_on_proxy} editTeam={false} onUpdate={handleRefreshClick} + premiumUser={premiumUser} /> ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index fa0ec06094..10616e9552 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -280,6 +280,7 @@ const TeamsView: React.FC = ({ is_proxy_admin={userRole == "Admin"} userModels={userModels} editTeam={editTeam} + premiumUser={premiumUser} /> ) : ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx index bf9cf92a99..df6d8d3ea8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx @@ -179,6 +179,20 @@ const CreateTeamModal = ({ formValues.metadata = JSON.stringify(metadata); } + if (formValues.secret_manager_settings) { + if (typeof formValues.secret_manager_settings === "string") { + if (formValues.secret_manager_settings.trim() === "") { + delete formValues.secret_manager_settings; + } else { + try { + formValues.secret_manager_settings = JSON.parse(formValues.secret_manager_settings); + } catch (e) { + throw new Error("Failed to parse secret manager settings: " + e); + } + } + } + } + // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission if ( (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || @@ -438,6 +452,36 @@ const CreateTeamModal = ({ > + { + if (!value) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index 996f17f14c..f3b4ec82d5 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -1,14 +1,17 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; -import { teamCreateCall } from "./networking"; +import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; import OldTeams from "./OldTeams"; +const mockTeamInfoView = vi.fn(); + vi.mock("./networking", () => ({ teamCreateCall: vi.fn(), teamDeleteCall: vi.fn(), fetchMCPAccessGroups: vi.fn(), v2TeamListCall: vi.fn(), + getGuardrailsList: vi.fn(), })); vi.mock("./common_components/fetch_teams", () => ({ @@ -46,9 +49,21 @@ vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({ }), })); +vi.mock("@/components/team/team_info", () => ({ + __esModule: true, + default: (props: any) => { + mockTeamInfoView(props); + return ; + }, +})); + describe("OldTeams - handleCreate organization handling", () => { beforeEach(() => { vi.clearAllMocks(); + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); }); it("should not include organization_id when it's an empty string", async () => { @@ -490,6 +505,56 @@ describe("OldTeams - helper functions", () => { }); }); +describe("OldTeams - premium props", () => { + beforeEach(() => { + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + }); + + it("passes premiumUser flag to TeamInfoView", async () => { + render( + , + ); + + const truncatedTeamId = "team-123456789".slice(0, 7); + const teamButton = await screen.findByRole("button", { + name: new RegExp(`${truncatedTeamId}\\.\\.\\.`), + }); + act(() => { + fireEvent.click(teamButton); + }); + + await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); + + expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ premiumUser: true })); + }); +}); + describe("OldTeams - Default Team Settings tab visibility", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 77d106c4ed..562d75c327 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -407,6 +407,20 @@ const Teams: React.FC = ({ formValues.metadata = JSON.stringify(metadata); } + if (formValues.secret_manager_settings) { + if (typeof formValues.secret_manager_settings === "string") { + if (formValues.secret_manager_settings.trim() === "") { + delete formValues.secret_manager_settings; + } else { + try { + formValues.secret_manager_settings = JSON.parse(formValues.secret_manager_settings); + } catch (e) { + throw new Error("Failed to parse secret manager settings: " + e); + } + } + } + } + // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission if ( (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || @@ -619,6 +633,7 @@ const Teams: React.FC = ({ is_proxy_admin={userRole == "Admin"} userModels={userModels} editTeam={editTeam} + premiumUser={premiumUser} /> ) : ( @@ -1246,6 +1261,36 @@ const Teams: React.FC = ({ > + { + if (!value) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + diff --git a/ui/litellm-dashboard/src/components/team/team_info.test.tsx b/ui/litellm-dashboard/src/components/team/team_info.test.tsx index 3a87b42d25..c8753660e0 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.test.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.test.tsx @@ -274,4 +274,138 @@ describe("TeamInfoView", () => { }); }); }, 10000); + + it("should disable secret manager settings for non-premium users", async () => { + const teamResponse = { + team_id: "123", + team_info: { + team_alias: "Test Team", + team_id: "123", + organization_id: null, + admins: ["admin@test.com"], + members: [], + members_with_roles: [], + metadata: { + secret_manager_settings: { provider: "aws", secret_id: "abc" }, + }, + tpm_limit: null, + rpm_limit: null, + max_budget: null, + budget_duration: null, + models: ["gpt-4"], + blocked: false, + spend: 0, + max_parallel_requests: null, + budget_reset_at: null, + model_id: null, + litellm_model_table: null, + created_at: "2024-01-01T00:00:00Z", + team_member_budget_table: null, + }, + keys: [], + team_memberships: [], + }; + + vi.mocked(networking.teamInfoCall).mockResolvedValue(teamResponse as any); + vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); + + render( + {}} + onClose={() => {}} + accessToken="123" + is_team_admin={true} + is_proxy_admin={true} + userModels={["gpt-4"]} + editTeam={false} + premiumUser={false} + />, + ); + + const settingsTab = await screen.findByRole("tab", { name: "Settings" }); + act(() => fireEvent.click(settingsTab)); + + const editButton = await screen.findByRole("button", { name: "Edit Settings" }); + act(() => fireEvent.click(editButton)); + + const secretField = await screen.findByPlaceholderText('{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}'); + expect(secretField).toBeDisabled(); + expect(secretField).toHaveValue(JSON.stringify(teamResponse.team_info.metadata.secret_manager_settings, null, 2)); + }, 10000); + + it("should allow premium users to update secret manager settings", async () => { + const teamResponse = { + team_id: "123", + team_info: { + team_alias: "Test Team", + team_id: "123", + organization_id: null, + admins: ["admin@test.com"], + members: [], + members_with_roles: [], + metadata: { + secret_manager_settings: { provider: "aws", secret_id: "abc" }, + }, + tpm_limit: null, + rpm_limit: null, + max_budget: null, + budget_duration: null, + models: ["gpt-4"], + blocked: false, + spend: 0, + max_parallel_requests: null, + budget_reset_at: null, + model_id: null, + litellm_model_table: null, + created_at: "2024-01-01T00:00:00Z", + team_member_budget_table: null, + }, + keys: [], + team_memberships: [], + }; + + vi.mocked(networking.teamInfoCall).mockResolvedValue(teamResponse as any); + vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: teamResponse.team_info, team_id: "123" } as any); + + render( + {}} + onClose={() => {}} + accessToken="123" + is_team_admin={true} + is_proxy_admin={true} + userModels={["gpt-4"]} + editTeam={false} + premiumUser={true} + />, + ); + + const settingsTab = await screen.findByRole("tab", { name: "Settings" }); + act(() => fireEvent.click(settingsTab)); + + const editButton = await screen.findByRole("button", { name: "Edit Settings" }); + act(() => fireEvent.click(editButton)); + + const secretField = await screen.findByPlaceholderText('{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}'); + expect(secretField).not.toBeDisabled(); + + act(() => { + fireEvent.change(secretField, { target: { value: '{"provider":"azure","secret_id":"xyz"}' } }); + }); + + const saveButton = await screen.findByRole("button", { name: "Save Changes" }); + act(() => fireEvent.click(saveButton)); + + await waitFor(() => { + expect(networking.teamUpdateCall).toHaveBeenCalled(); + }); + + const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1]; + expect(payload.metadata.secret_manager_settings).toEqual({ provider: "azure", secret_id: "xyz" }); + }, 10000); }); diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index f2ca96c9d6..83b5c1d475 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -375,6 +375,19 @@ const TeamInfoView: React.FC = ({ return; } + let secretManagerSettings: Record | undefined; + if (typeof values.secret_manager_settings === "string") { + const trimmedSecretConfig = values.secret_manager_settings.trim(); + if (trimmedSecretConfig.length > 0) { + try { + secretManagerSettings = JSON.parse(values.secret_manager_settings); + } catch (e) { + NotificationsManager.fromBackend("Invalid JSON in secret manager settings"); + return; + } + } + } + const sanitizeNumeric = (v: any) => { if (v === null || v === undefined) return null; if (typeof v === "string" && v.trim() === "") return null; @@ -394,6 +407,7 @@ const TeamInfoView: React.FC = ({ ...parsedMetadata, guardrails: values.guardrails || [], logging: values.logging_settings || [], + ...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}), }, organization_id: values.organization_id, }; @@ -634,9 +648,16 @@ const TeamInfoView: React.FC = ({ guardrails: info.metadata?.guardrails || [], disable_global_guardrails: info.metadata?.disable_global_guardrails || false, metadata: info.metadata - ? JSON.stringify((({ logging, ...rest }) => rest)(info.metadata), null, 2) + ? JSON.stringify( + (({ logging, secret_manager_settings, ...rest }) => rest)(info.metadata), + null, + 2, + ) : "", logging_settings: info.metadata?.logging || [], + secret_manager_settings: info.metadata?.secret_manager_settings + ? JSON.stringify(info.metadata.secret_manager_settings, null, 2) + : "", organization_id: info.organization_id, vector_stores: info.object_permission?.vector_stores || [], mcp_servers: info.object_permission?.mcp_servers || [], @@ -874,6 +895,37 @@ const TeamInfoView: React.FC = ({ /> + { + if (!value) { + return Promise.resolve(); + } + try { + JSON.parse(value); + return Promise.resolve(); + } catch (error) { + return Promise.reject(new Error("Please enter valid JSON")); + } + }, + }, + ]} + > + + + @@ -971,6 +1023,15 @@ const TeamInfoView: React.FC = ({ variant="inline" className="pt-4 border-t border-gray-200" /> + + {info.metadata?.secret_manager_settings && ( + + Secret Manager Settings + + {JSON.stringify(info.metadata.secret_manager_settings, null, 2)} + + + )} )}
+ {JSON.stringify(info.metadata.secret_manager_settings, null, 2)} +