Skip to content

Commit a2d8cde

Browse files
committed
feat(new service account flow): service account owners creation
1 parent d3ecaf1 commit a2d8cde

3 files changed

Lines changed: 118 additions & 1 deletion

File tree

litellm/proxy/_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,7 @@ class NewUserRequest(GenerateRequestBase):
14311431
send_invite_email: Optional[bool] = None
14321432
sso_user_id: Optional[str] = None
14331433
organizations: Optional[List[str]] = None
1434+
owner_ids: Optional[List[str]] = None
14341435

14351436

14361437
class NewUserResponse(GenerateKeyResponse):

litellm/proxy/management_endpoints/internal_user_endpoints.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ async def new_user(
482482
organization_ids = cast(
483483
Optional[List[str]], data_json.pop("organizations", None)
484484
)
485+
owner_ids = cast(Optional[List[str]], data_json.pop("owner_ids", None))
485486

486487
response = await generate_key_helper_fn(request_type="user", **data_json)
487488
# Admin UI Logic
@@ -516,6 +517,21 @@ async def new_user(
516517
user_api_key_dict=user_api_key_dict,
517518
)
518519

520+
# If owner_ids provided, create a service account entry linked to this user
521+
if owner_ids is not None and len(owner_ids) > 0 and user_id is not None:
522+
if len(data.owner_ids) < 2:
523+
raise HTTPException(
524+
status_code=400,
525+
detail="owner_ids must contain at least 2 user IDs",
526+
)
527+
await prisma_client.db.litellm_serviceaccounttable.upsert(
528+
where={"user_id": user_id},
529+
data={
530+
"create": {"user_id": user_id, "owner_ids": owner_ids},
531+
"update": {"owner_ids": owner_ids},
532+
},
533+
)
534+
519535
special_keys = ["token", "token_id"]
520536
response_dict = {}
521537
for key, value in response.items():

ui/litellm-dashboard/src/components/CreateUserButton.tsx

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { useQueryClient } from "@tanstack/react-query";
33
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
44
import { Accordion, AccordionBody, AccordionHeader, SelectItem, TextInput } from "@tremor/react";
55
import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd";
6-
import React, { useEffect, useMemo, useState } from "react";
6+
import debounce from "lodash/debounce";
7+
import React, { useCallback, useEffect, useMemo, useState } from "react";
78
import BulkCreateUsers from "./bulk_create_users_button";
89
import TeamDropdown from "./common_components/team_dropdown";
910
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
@@ -14,6 +15,7 @@ import {
1415
invitationCreateCall,
1516
modelAvailableCall,
1617
userCreateCall,
18+
userFilterUICall,
1719
} from "./networking";
1820
import OnboardingModal, { InvitationLink } from "./onboarding_link";
1921
const { Option } = Select;
@@ -66,6 +68,33 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
6668
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
6769
const [baseUrl, setBaseUrl] = useState<string | null>(null);
6870
const { data: organizations = [] } = useOrganizations();
71+
const [ownerOptions, setOwnerOptions] = useState<{ label: string; value: string }[]>([]);
72+
const [ownerSearchLoading, setOwnerSearchLoading] = useState(false);
73+
74+
const fetchOwners = async (searchText: string) => {
75+
if (!searchText) {
76+
setOwnerOptions([]);
77+
return;
78+
}
79+
setOwnerSearchLoading(true);
80+
try {
81+
const params = new URLSearchParams();
82+
params.append("user_email", searchText);
83+
const results = await userFilterUICall(accessToken, params);
84+
setOwnerOptions(
85+
results.map((u: { user_email: string; user_id: string }) => ({
86+
label: `${u.user_email} (${u.user_id})`,
87+
value: u.user_id,
88+
}))
89+
);
90+
} catch {
91+
// silently ignore search errors
92+
} finally {
93+
setOwnerSearchLoading(false);
94+
}
95+
};
96+
97+
const debouncedOwnerSearch = useCallback(debounce(fetchOwners, 300), [accessToken]);
6998

7099
// Derive teams from the user's organizations, falling back to the teams prop
71100
const availableTeams = useMemo(() => {
@@ -113,6 +142,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
113142
user_role: string;
114143
organization_ids?: string[];
115144
organizations?: string[];
145+
owner_ids?: string[];
116146
}) => {
117147
try {
118148
NotificationsManager.info("Making API Call");
@@ -213,6 +243,41 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
213243
<TeamDropdown />
214244
</Form.Item>
215245

246+
<Form.Item
247+
label={
248+
<span>
249+
Owners{" "}
250+
<Tooltip title="At least 2 users who own this service account">
251+
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
252+
</Tooltip>
253+
</span>
254+
}
255+
name="owner_ids"
256+
required={false}
257+
rules={[
258+
{
259+
validator: async (_, value) => {
260+
if (value && value.length > 0 && value.length < 2) {
261+
throw new Error("If specifying owners, please select at least 2");
262+
}
263+
},
264+
},
265+
]}
266+
help="optional — if provided, minimum 2 owners"
267+
>
268+
<Select2
269+
mode="multiple"
270+
showSearch
271+
placeholder="Search by email to add owners"
272+
filterOption={false}
273+
onSearch={(v) => debouncedOwnerSearch(v)}
274+
loading={ownerSearchLoading}
275+
options={ownerOptions}
276+
notFoundContent={ownerSearchLoading ? "Searching..." : "No users found"}
277+
style={{ width: "100%" }}
278+
/>
279+
</Form.Item>
280+
216281
<Form.Item label="Metadata" name="metadata">
217282
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
218283
</Form.Item>
@@ -309,6 +374,41 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
309374
</Select>
310375
</Form.Item>
311376

377+
<Form.Item
378+
label={
379+
<span>
380+
Owners{" "}
381+
<Tooltip title="At least 2 users who own this service account">
382+
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
383+
</Tooltip>
384+
</span>
385+
}
386+
name="owner_ids"
387+
required={false}
388+
rules={[
389+
{
390+
validator: async (_, value) => {
391+
if (value && value.length > 0 && value.length < 2) {
392+
throw new Error("If specifying owners, please select at least 2");
393+
}
394+
},
395+
},
396+
]}
397+
help="optional — if provided, minimum 2 owners"
398+
>
399+
<Select2
400+
mode="multiple"
401+
showSearch
402+
placeholder="Search by email to add owners"
403+
filterOption={false}
404+
onSearch={(v) => debouncedOwnerSearch(v)}
405+
loading={ownerSearchLoading}
406+
options={ownerOptions}
407+
notFoundContent={ownerSearchLoading ? "Searching..." : "No users found"}
408+
style={{ width: "100%" }}
409+
/>
410+
</Form.Item>
411+
312412
<Form.Item label="Metadata" name="metadata">
313413
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
314414
</Form.Item>

0 commit comments

Comments
 (0)