Merge pull request #18149 from BerriAI/litellm_feat_add_secret_manager_settings
feat: add secret manager settings controls to team management UI
This commit is contained in:
commit
c694d96da0
@ -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.
|
||||
|
||||
<Image img={require('../../img/secret_manager_settings.png')} />
|
||||
|
||||
|
||||
Refer to each provider’s documentation (AWS, Azure, Google, Hashicorp, etc.) for the supported keys/values you can place inside `secret_manager_settings`.
|
||||
|
||||
BIN
docs/my-website/img/secret_manager_settings.png
Normal file
BIN
docs/my-website/img/secret_manager_settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -571,6 +571,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({
|
||||
userModels={all_models_on_proxy}
|
||||
editTeam={false}
|
||||
onUpdate={handleRefreshClick}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -280,6 +280,7 @@ const TeamsView: React.FC<TeamProps> = ({
|
||||
is_proxy_admin={userRole == "Admin"}
|
||||
userModels={userModels}
|
||||
editTeam={editTeam}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : (
|
||||
<TeamsHeaderTabs lastRefreshed={lastRefreshed} onRefresh={handleRefreshClick} userRole={userRole}>
|
||||
|
||||
@ -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 = ({
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Secret Manager Settings"
|
||||
name="secret_manager_settings"
|
||||
help={
|
||||
premiumUser
|
||||
? "Enter secret manager configuration as a JSON object."
|
||||
: "Premium feature - Upgrade to manage secret manager settings."
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error("Please enter valid JSON"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder='{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}'
|
||||
disabled={!premiumUser}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
|
||||
@ -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 <div data-testid="team-info-view" />;
|
||||
},
|
||||
}));
|
||||
|
||||
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(
|
||||
<OldTeams
|
||||
teams={[
|
||||
{
|
||||
team_id: "team-123456789",
|
||||
team_alias: "Premium Team",
|
||||
organization_id: "org-123",
|
||||
models: ["gpt-4"],
|
||||
max_budget: 100,
|
||||
budget_duration: "1d",
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
},
|
||||
]}
|
||||
searchParams={{}}
|
||||
accessToken="test-token"
|
||||
setTeams={vi.fn()}
|
||||
userID="user-123"
|
||||
userRole="Admin"
|
||||
organizations={[]}
|
||||
premiumUser={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@ -407,6 +407,20 @@ const Teams: React.FC<TeamProps> = ({
|
||||
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<TeamProps> = ({
|
||||
is_proxy_admin={userRole == "Admin"}
|
||||
userModels={userModels}
|
||||
editTeam={editTeam}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : (
|
||||
<TabGroup className="gap-2 h-[75vh] w-full">
|
||||
@ -1246,6 +1261,36 @@ const Teams: React.FC<TeamProps> = ({
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Secret Manager Settings"
|
||||
name="secret_manager_settings"
|
||||
help={
|
||||
premiumUser
|
||||
? "Enter secret manager configuration as a JSON object."
|
||||
: "Premium feature - Upgrade to manage secret manager settings."
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error("Please enter valid JSON"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder='{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}'
|
||||
disabled={!premiumUser}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
|
||||
@ -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(
|
||||
<TeamInfoView
|
||||
teamId="123"
|
||||
onUpdate={() => {}}
|
||||
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(
|
||||
<TeamInfoView
|
||||
teamId="123"
|
||||
onUpdate={() => {}}
|
||||
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);
|
||||
});
|
||||
|
||||
@ -375,6 +375,19 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let secretManagerSettings: Record<string, any> | 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<TeamInfoProps> = ({
|
||||
...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<TeamInfoProps> = ({
|
||||
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<TeamInfoProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Secret Manager Settings"
|
||||
name="secret_manager_settings"
|
||||
help={
|
||||
premiumUser
|
||||
? "Enter secret manager configuration as a JSON object."
|
||||
: "Premium feature - Upgrade to manage secret manager settings."
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error("Please enter valid JSON"));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
placeholder='{"namespace": "admin", "mount": "secret", "path_prefix": "litellm"}'
|
||||
disabled={!premiumUser}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={10} />
|
||||
</Form.Item>
|
||||
@ -971,6 +1023,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
/>
|
||||
|
||||
{info.metadata?.secret_manager_settings && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Text className="font-medium">Secret Manager Settings</Text>
|
||||
<pre className="mt-2 bg-gray-50 p-3 rounded text-xs overflow-x-auto">
|
||||
{JSON.stringify(info.metadata.secret_manager_settings, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user