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:
YutaSaito 2025-12-18 06:57:12 +09:00 committed by GitHub
commit c694d96da0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 366 additions and 2 deletions

View File

@ -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 providers documentation (AWS, Azure, Google, Hashicorp, etc.) for the supported keys/values you can place inside `secret_manager_settings`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -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",
]

View File

@ -571,6 +571,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({
userModels={all_models_on_proxy}
editTeam={false}
onUpdate={handleRefreshClick}
premiumUser={premiumUser}
/>
</div>
);

View File

@ -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}>

View File

@ -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>

View File

@ -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();

View File

@ -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>

View File

@ -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);
});

View File

@ -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>