mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat(customers): move existing members between teams (#51913)
This commit is contained in:
parent
c3b929d203
commit
00313c9e5b
9 changed files with 455 additions and 21 deletions
|
|
@ -471,7 +471,9 @@ async def get_customer_member(
|
|||
@router.patch(
|
||||
"/customer_members/{customer_member_id}",
|
||||
response_model=CustomerMember,
|
||||
description="Update a customer member and its budgets. Proxy admins only.",
|
||||
description="Update a customer member and its budgets. Supply parent_team_ids to replace its teams within the "
|
||||
"same organization, with the first team as default. Identity, budget counters and historical usage are retained. "
|
||||
"Historical usage stays attributed to the original team. Proxy admins only.",
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def update_customer_member(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.types.proxy.management_endpoints.customer_hierarchy import (
|
|||
BudgetTemplate,
|
||||
CustomerMember,
|
||||
MemberCreate,
|
||||
MemberFields,
|
||||
MemberPatch,
|
||||
Organization,
|
||||
OrganizationBudget,
|
||||
|
|
@ -371,7 +372,7 @@ def team_response(row: PrismaRecord, now: datetime) -> Team:
|
|||
|
||||
|
||||
def override_expiry(
|
||||
row: Team | CustomerMember | None, payload: TeamPatch | MemberPatch, override_id: str | None
|
||||
row: Team | CustomerMember | None, payload: TeamPatch | MemberFields, override_id: str | None
|
||||
) -> datetime | None:
|
||||
if override_id is None:
|
||||
return None
|
||||
|
|
@ -995,9 +996,20 @@ class CustomerHierarchyStore:
|
|||
return await self.update_budget(current_id, payload.budget_override, actor)
|
||||
|
||||
async def update_member(self, member_id: str, payload: MemberPatch, actor: str) -> CustomerMember | Failure:
|
||||
if payload.parent_team_ids is not None:
|
||||
await self.lock_customer_identities()
|
||||
member: Final = await self.get_member(member_id)
|
||||
if isinstance(member, Failure):
|
||||
return member
|
||||
if payload.parent_team_ids is not None:
|
||||
teams: Final = await find_many_in(self.teams, "customer_team_id", payload.parent_team_ids)
|
||||
if len(teams) != len(payload.parent_team_ids):
|
||||
return Failure(ErrorCode.PARENT_NOT_FOUND)
|
||||
if any(team.model_dump()["parent_org_id"] != member.parent_org_id for team in teams):
|
||||
return Failure(ErrorCode.CUSTOMER_ORG_MISMATCH)
|
||||
added: Final = tuple(team for team in payload.parent_team_ids if team not in member.parent_team_ids)
|
||||
if await self.member_capacity_reached(added):
|
||||
return Failure(ErrorCode.FREE_TIER_LIMIT)
|
||||
selected: Final = await self.member_template_patch(member, payload)
|
||||
if isinstance(selected, Failure):
|
||||
return selected
|
||||
|
|
@ -1012,6 +1024,7 @@ class CustomerHierarchyStore:
|
|||
where={"customer_member_id": member_id},
|
||||
data={
|
||||
**alias_patch(payload),
|
||||
**({"parent_team_ids": payload.parent_team_ids} if payload.parent_team_ids is not None else {}),
|
||||
**({"budget_id": selected} if "budget_id" in payload.model_fields_set else {}),
|
||||
**(
|
||||
{
|
||||
|
|
@ -1154,6 +1167,7 @@ class CustomerHierarchyStore:
|
|||
return None
|
||||
|
||||
async def delete_team(self, team_id: str, *, cascade: bool = False) -> None | Failure:
|
||||
await self.lock_customer_identities()
|
||||
team: Final = await self.get_team(team_id)
|
||||
if isinstance(team, Failure):
|
||||
return team
|
||||
|
|
@ -1166,6 +1180,7 @@ class CustomerHierarchyStore:
|
|||
return None
|
||||
|
||||
async def delete_organization(self, organization_id: str, *, cascade: bool = False) -> None | Failure:
|
||||
await self.lock_customer_identities()
|
||||
organization: Final = await self.get_organization(organization_id)
|
||||
if isinstance(organization, Failure):
|
||||
return organization
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from collections.abc import Mapping
|
|||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from json import dumps
|
||||
from typing import Final, Literal, TypeAlias
|
||||
from typing import Annotated, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
|
|
@ -14,6 +14,7 @@ from pydantic import (
|
|||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from litellm.models.budget import BudgetLimits
|
||||
from litellm.proxy.common_utils.timezone_utils import budget_duration_error
|
||||
|
|
@ -116,7 +117,7 @@ class TeamCreate(TeamPatch):
|
|||
parent_org_id: str
|
||||
|
||||
|
||||
class MemberPatch(BaseModel):
|
||||
class MemberFields(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
alias: str | None = None
|
||||
|
|
@ -125,13 +126,28 @@ class MemberPatch(BaseModel):
|
|||
budget_override_expires_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class MemberCreate(MemberPatch):
|
||||
class MemberPatch(MemberFields):
|
||||
parent_team_ids: Annotated[tuple[str, ...], Field(min_length=1)] | SkipJsonSchema[None] = Field(
|
||||
default=None,
|
||||
description="Replace the member's teams within its existing organization. First team is the default. "
|
||||
"Omit to keep current teams. Member identity, budgets and historical usage are retained.",
|
||||
)
|
||||
|
||||
@field_validator("parent_team_ids")
|
||||
@classmethod
|
||||
def valid_parent_teams(cls, value: tuple[str, ...] | None) -> tuple[str, ...]:
|
||||
if value is None or any(not team.strip() for team in value) or len(set(value)) != len(value):
|
||||
raise ValueError("parent_team_ids must contain unique, nonempty team IDs")
|
||||
return value
|
||||
|
||||
|
||||
class MemberCreate(MemberFields):
|
||||
customer_member_id: str | None = None
|
||||
parent_org_id: str | None = Field(default=None, description="Must match the parent teams' organization when set")
|
||||
parent_team_ids: tuple[str, ...] = Field(min_length=1, description="Teams the member belongs to, first is default")
|
||||
|
||||
|
||||
class MemberUpsert(MemberPatch):
|
||||
class MemberUpsert(MemberFields):
|
||||
customer_member_id: str = Field(min_length=1)
|
||||
customer_org_id: str | None = None
|
||||
customer_org_alias: str | None = None
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, Pencil, Plus } from "lucide-react";
|
||||
import { ChevronRight, MoreHorizontal, Pencil, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ErrorState } from "@/components/shared/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import MoveCustomerMemberDialog from "./MoveCustomerMemberDialog";
|
||||
import type { Member } from "./customerHierarchyForms";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import CustomerHierarchyNavigation, { useCustomerHierarchyLinks } from "./CustomerHierarchyNavigation";
|
||||
|
|
@ -126,6 +134,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
|
|||
entity: customerUsageEntity(team.customer_team_id, team),
|
||||
open: () => hierarchy.openTeam(team.customer_team_id),
|
||||
edit: () => hierarchy.openTeam(team.customer_team_id, true),
|
||||
member: null,
|
||||
parentTeamId: null,
|
||||
openParentTeam: () => hierarchy.openTeam(team.customer_team_id),
|
||||
usageScope: { org: scope.org, team: team.customer_team_id, member: null },
|
||||
|
|
@ -137,6 +146,7 @@ function hierarchyRows({ hierarchy, selected, scope }: ScopeProps) {
|
|||
entity: customerUsageEntity(member.customer_member_id, member),
|
||||
open: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""),
|
||||
edit: () => hierarchy.openMember(member.customer_member_id, member.parent_team_ids[0] ?? ""),
|
||||
member,
|
||||
parentTeamId: member.parent_team_ids[0] ?? "",
|
||||
openParentTeam: () => hierarchy.openTeam(member.parent_team_ids[0] ?? ""),
|
||||
usageScope: { org: scope.org, team: selected.team?.customer_team_id ?? null, member: member.customer_member_id },
|
||||
|
|
@ -178,6 +188,7 @@ function HierarchyCreateAction({ hierarchy, selected, scope }: ScopeProps) {
|
|||
|
||||
function HierarchyChildTable(props: ScopeProps) {
|
||||
const { customerHierarchyHref } = useCustomerHierarchyLinks();
|
||||
const [movingMember, setMovingMember] = useState<Member | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const { hierarchy } = props;
|
||||
const { children, childUsage, childTab, page } = hierarchy;
|
||||
|
|
@ -272,16 +283,38 @@ function HierarchyChildTable(props: ScopeProps) {
|
|||
>
|
||||
{t("customerPortal.hierarchy.viewUsage")}
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={row.edit}
|
||||
aria-label={`${t("customerPortal.hierarchy.edit")} ${row.label}`}
|
||||
>
|
||||
<Pencil aria-hidden="true" className="size-3.5" />
|
||||
{t("customerPortal.hierarchy.edit")}
|
||||
</Button>
|
||||
{row.member ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("customerPortal.hierarchy.memberActions", { name: row.label })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={row.edit}>{t("customerPortal.hierarchy.settings")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setMovingMember(row.member)}>
|
||||
{t("customerPortal.hierarchy.moveTeams")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={row.edit}
|
||||
aria-label={`${t("customerPortal.hierarchy.edit")} ${row.label}`}
|
||||
>
|
||||
<Pencil aria-hidden="true" className="size-3.5" />
|
||||
{t("customerPortal.hierarchy.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -299,6 +332,13 @@ function HierarchyChildTable(props: ScopeProps) {
|
|||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{movingMember && (
|
||||
<MoveCustomerMemberDialog
|
||||
member={movingMember}
|
||||
organizationId={props.scope.org}
|
||||
onClose={() => setMovingMember(null)}
|
||||
/>
|
||||
)}
|
||||
{pages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between border-t pt-4 text-xs text-muted-foreground">
|
||||
<span>{t("customerPortal.hierarchy.pagination", { page, pages })}</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchClient } from "@/lib/http/api";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Team, Member } from "./customerHierarchyForms";
|
||||
|
||||
async function teamChoices(organizationId: string, signal: AbortSignal, page = 1): Promise<Team[]> {
|
||||
if (page > 1_000) throw new Error("Customer team list is incomplete");
|
||||
const response = await fetchClient.GET("/management/v1/customer_teams", {
|
||||
params: { query: { parent_org_id: organizationId, page, page_size: 100 } },
|
||||
signal,
|
||||
});
|
||||
if (!response.data) throw new Error("Missing customer teams response");
|
||||
if (page >= response.data.meta.total_pages) return response.data.data;
|
||||
return [...response.data.data, ...(await teamChoices(organizationId, signal, page + 1))];
|
||||
}
|
||||
|
||||
function CustomerMemberTeams({
|
||||
organizationId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
organizationId: string;
|
||||
value: readonly string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const teams = useQuery({
|
||||
queryKey: ["customer-hierarchy", "team-choices", organizationId],
|
||||
queryFn: ({ signal }) => teamChoices(organizationId, signal),
|
||||
});
|
||||
const options = (teams.data ?? []).map((team) => ({
|
||||
value: team.customer_team_id,
|
||||
label: team.alias || team.customer_team_id,
|
||||
description: team.alias ? team.customer_team_id : undefined,
|
||||
}));
|
||||
const label = (id: string) => options.find((option) => option.value === id)?.label ?? id;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("customerPortal.hierarchy.memberTeamsHint")}</p>
|
||||
<div className={value.length > 1 ? "grid gap-4 sm:grid-cols-2" : "space-y-4"}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-teams">{t("customerPortal.hierarchy.teams")}</Label>
|
||||
<MultiSelect
|
||||
id="member-teams"
|
||||
options={options}
|
||||
value={[...value]}
|
||||
onValueChange={onChange}
|
||||
loading={teams.isLoading}
|
||||
disabled={!teams.isSuccess}
|
||||
placeholder={t("customerPortal.hierarchy.chooseTeams")}
|
||||
/>
|
||||
</div>
|
||||
{value.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-default-team">{t("customerPortal.hierarchy.defaultTeam")}</Label>
|
||||
<Select
|
||||
value={value[0]}
|
||||
onValueChange={(id) => id && onChange([id, ...value.filter((team) => team !== id)])}
|
||||
>
|
||||
<SelectTrigger id="member-default-team" className="w-full">
|
||||
<SelectValue>{label(value[0])}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{value.map((id) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{label(id)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t("customerPortal.hierarchy.defaultTeamHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{teams.isError && (
|
||||
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
|
||||
{t("customerPortal.hierarchy.teamsLoadError")}
|
||||
<Button type="button" variant="link" size="sm" onClick={() => void teams.refetch()}>
|
||||
{t("customerPortal.hierarchy.retryTeams")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveForm({
|
||||
member,
|
||||
organizationId,
|
||||
onClose,
|
||||
pending,
|
||||
setPending,
|
||||
}: {
|
||||
member: Member;
|
||||
organizationId: string;
|
||||
onClose: () => void;
|
||||
pending: boolean;
|
||||
setPending: (pending: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [teams, setTeams] = useState(member.parent_team_ids);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dirty = JSON.stringify(teams) !== JSON.stringify(member.parent_team_ids);
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!teams.length || !dirty) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchClient.PATCH("/management/v1/customer_members/{customer_member_id}", {
|
||||
params: { path: { customer_member_id: member.customer_member_id } },
|
||||
body: { parent_team_ids: teams },
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["customer-hierarchy"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["get", "/management/v1/customer_portal/limits"] });
|
||||
toast.success(t("customerPortal.hierarchy.memberMoved"));
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(extractProxyErrorMessage(cause));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<DialogContent className="sm:max-w-xl" showCloseButton={!pending}>
|
||||
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("customerPortal.hierarchy.moveTeams")}</DialogTitle>
|
||||
<DialogDescription>{member.alias || member.customer_member_id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<fieldset disabled={pending}>
|
||||
<CustomerMemberTeams organizationId={organizationId} value={teams} onChange={setTeams} />
|
||||
</fieldset>
|
||||
{!teams.length && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t("customerPortal.hierarchy.teamsRequired")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={pending} onClick={onClose}>
|
||||
{t("customerPortal.hierarchy.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending || !dirty || !teams.length}>
|
||||
{t(pending ? "customerPortal.hierarchy.moving" : "customerPortal.hierarchy.moveTeams")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MoveCustomerMemberDialog({
|
||||
member,
|
||||
organizationId,
|
||||
onClose,
|
||||
}: {
|
||||
member: Member;
|
||||
organizationId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [pending, setPending] = useState(false);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pending) onClose();
|
||||
}}
|
||||
>
|
||||
<MoveForm
|
||||
member={member}
|
||||
organizationId={organizationId}
|
||||
onClose={onClose}
|
||||
pending={pending}
|
||||
setPending={setPending}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,6 +117,36 @@ function BrowserNavigationProbe() {
|
|||
}
|
||||
|
||||
describe("customer hierarchy management", () => {
|
||||
it("moves a member through its row menu without editing budgets", async () => {
|
||||
const user = userEvent.setup();
|
||||
const moved = { ...member, parent_team_ids: ["team-b"] };
|
||||
fetchMock.mockImplementation(async (input, init) => {
|
||||
const url = requestUrl(input);
|
||||
const method = input instanceof Request ? input.method : init?.method;
|
||||
if (url.pathname === "/management/v1/customer_members/member-a" && method === "PATCH") return json(moved);
|
||||
return serve(input);
|
||||
});
|
||||
const options = { searchParams: "?org=org-1&tab=members" };
|
||||
renderWithProviders(<CustomerHierarchyManager />, options);
|
||||
await user.click(await screen.findByRole("button", { name: "Actions for Member One" }));
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Move teams" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Move teams" });
|
||||
const teams = within(dialog).getByRole("combobox", { name: "Choose teams" });
|
||||
await waitFor(() => expect(teams).toBeEnabled());
|
||||
await user.click(teams);
|
||||
await user.click(screen.getByRole("option", { name: /Operations/ }));
|
||||
await user.click(screen.getByRole("option", { name: /Product/ }));
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Move teams" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
const write = fetchMock.mock.calls
|
||||
.map(([input]) => input)
|
||||
.find((input) => input instanceof Request && input.method === "PATCH");
|
||||
expect(write).toBeInstanceOf(Request);
|
||||
expect(await (write as Request).clone().json()).toEqual({ parent_team_ids: ["team-b"] });
|
||||
});
|
||||
|
||||
it.each(["teams", "members"] as const)(
|
||||
"should show usage-style budgets, spend share, tokens and actions for customer %s",
|
||||
async (kind) => {
|
||||
|
|
@ -195,7 +225,9 @@ describe("customer hierarchy management", () => {
|
|||
);
|
||||
expect(within(table).getByRole("cell", { name: "250" })).toBeInTheDocument();
|
||||
expect(within(table).getByText("Within budget")).toBeInTheDocument();
|
||||
expect(within(table).getByRole("button", { name: `Edit ${label}` })).toBeEnabled();
|
||||
expect(
|
||||
within(table).getByRole("button", { name: kind === "teams" ? `Edit ${label}` : `Actions for ${label}` }),
|
||||
).toBeEnabled();
|
||||
const validRow = within(table).getByRole("row", { name: new RegExp(label) });
|
||||
expect(within(validRow).getByRole("link", { name: "View usage" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -480,7 +512,9 @@ describe("customer hierarchy management", () => {
|
|||
.find((url) => url.pathname === "/management/v1/customer_members")
|
||||
?.searchParams.get("parent_team_id"),
|
||||
).toBe("team-a");
|
||||
await user.click(within(members).getByRole("button", { name: "Edit Member One" }));
|
||||
await user.click(within(members).getByRole("button", { name: "Actions for Member One" }));
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Settings" }));
|
||||
expect(await screen.findByRole("heading", { name: "Member One" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Manage settings and budget for this customer member.")).toBeInTheDocument();
|
||||
const usage = screen.getByRole("link", { name: "Usage" });
|
||||
|
|
|
|||
|
|
@ -20775,7 +20775,17 @@
|
|||
"deleteOrganizationMessage": "This deletes this customer organization and its customer teams and customer members.",
|
||||
"deleteTeamMessage": "This deletes this customer team and its customer members.",
|
||||
"deleteMemberMessage": "This deletes this customer member.",
|
||||
"deleteInformationTitle": "{{kind}} details"
|
||||
"deleteInformationTitle": "{{kind}} details",
|
||||
"teamsRequired": "Select at least one team.",
|
||||
"memberTeamsHint": "Changing teams keeps this member’s ID, budgets and spend history. Past usage stays with the original team.",
|
||||
"defaultTeam": "Default team",
|
||||
"defaultTeamHint": "Requests without a team use this team.",
|
||||
"retryTeams": "Retry",
|
||||
"chooseTeams": "Choose teams",
|
||||
"moveTeams": "Move teams",
|
||||
"moving": "Moving…",
|
||||
"memberMoved": "Member teams updated.",
|
||||
"memberActions": "Actions for {{name}}"
|
||||
},
|
||||
"switcher": {
|
||||
"label": "Customer Management",
|
||||
|
|
|
|||
7
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
7
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -9761,7 +9761,7 @@ export interface paths {
|
|||
head?: never;
|
||||
/**
|
||||
* Update Customer Member
|
||||
* @description Update a customer member and its budgets. Proxy admins only.
|
||||
* @description Update a customer member and its budgets. Supply parent_team_ids to replace its teams within the same organization, with the first team as default. Identity, budget counters and historical usage are retained. Historical usage stays attributed to the original team. Proxy admins only.
|
||||
*/
|
||||
patch: operations["update_customer_member_management_v1_customer_members__customer_member_id__patch"];
|
||||
trace?: never;
|
||||
|
|
@ -40893,6 +40893,11 @@ export interface components {
|
|||
budget_override?: components["schemas"]["Budget"] | null;
|
||||
/** Budget Override Expires At */
|
||||
budget_override_expires_at?: string | null;
|
||||
/**
|
||||
* Parent Team Ids
|
||||
* @description Replace the member's teams within its existing organization. First team is the default. Omit to keep current teams. Member identity, budgets and historical usage are retained.
|
||||
*/
|
||||
parent_team_ids?: string[];
|
||||
};
|
||||
/** MemberUpsert */
|
||||
MemberUpsert: {
|
||||
|
|
|
|||
|
|
@ -1931,3 +1931,114 @@ def test_list_and_detail_expose_each_effective_budget_with_its_own_recorded_spen
|
|||
assert detail.status_code == listing.status_code == 200
|
||||
assert listing.json()["data"][0]["budget_usage"] == detail.json()["budget_usage"]
|
||||
assert [(item["spend"], item["max_budget"]) for item in detail.json()["budget_usage"]] == [(0, 50), (8, 10)]
|
||||
|
||||
def test_member_team_replacement_retains_identity_budgets_and_spend() -> None:
|
||||
database: Final = Database()
|
||||
cache: Final = UserApiKeyCache()
|
||||
http: Final = client(database=database, cache=cache)
|
||||
family(http)
|
||||
assert (
|
||||
http.post(
|
||||
"/management/v1/customer_teams", json={"customer_team_id": "next", "parent_org_id": "org"}
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
created: Final = http.post(
|
||||
"/management/v1/customer_members",
|
||||
json={
|
||||
"customer_member_id": "moving",
|
||||
"parent_team_ids": ["team"],
|
||||
"budget_override": {"max_budget": 25, "budget_duration": "1mo"},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
asyncio.run(
|
||||
database.litellm_customermembertable.update(where={"customer_member_id": "moving"}, data={"spend": 7.5})
|
||||
)
|
||||
original: Final = http.get("/management/v1/customer_members/moving").json()
|
||||
cache.set_cache(customer_member_cache_key("moving"), "old-team")
|
||||
moved: Final = http.patch("/management/v1/customer_members/moving", json={"parent_team_ids": ["next"]})
|
||||
assert moved.status_code == 200, moved.text
|
||||
assert moved.json() == {**original, "parent_team_ids": ["next"]}
|
||||
assert cache.get_cache(customer_member_cache_key("moving")) is None
|
||||
assert http.get("/management/v1/customer_members", params={"parent_team_id": "team"}).json()["data"] == []
|
||||
assert [
|
||||
m["customer_member_id"]
|
||||
for m in http.get("/management/v1/customer_members", params={"parent_team_id": "next"}).json()["data"]
|
||||
] == ["moving"]
|
||||
renamed: Final = http.patch("/management/v1/customer_members/moving", json={"alias": "Renamed"})
|
||||
assert renamed.status_code == 200, renamed.text
|
||||
assert renamed.json()["parent_team_ids"] == ["next"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"teams,status",
|
||||
((None, 422), ([], 422), ([""], 422), (["team", "team"], 422), (["missing"], 404), (["foreign"], 409)),
|
||||
)
|
||||
def test_member_team_replacement_rejects_invalid_parents_atomically(teams: object, status: int) -> None:
|
||||
http: Final = client()
|
||||
family(http)
|
||||
assert http.post("/management/v1/customer_organizations", json={"customer_org_id": "other"}).status_code == 201
|
||||
assert (
|
||||
http.post(
|
||||
"/management/v1/customer_teams", json={"customer_team_id": "foreign", "parent_org_id": "other"}
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
original: Final = http.post(
|
||||
"/management/v1/customer_members", json={"customer_member_id": "moving", "parent_team_ids": ["team"]}
|
||||
)
|
||||
assert original.status_code == 201, original.text
|
||||
rejected: Final = http.patch(
|
||||
"/management/v1/customer_members/moving", json={"parent_team_ids": teams, "alias": "Must not change"}
|
||||
)
|
||||
assert rejected.status_code == status, rejected.text
|
||||
assert http.get("/management/v1/customer_members/moving").json() == original.json()
|
||||
|
||||
|
||||
def test_member_team_replacement_schema_allows_omission_but_not_null() -> None:
|
||||
http: Final = client()
|
||||
response: Final = http.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
spec: Final = response.json()
|
||||
request_schema: Final = spec["paths"]["/management/v1/customer_members/{customer_member_id}"]["patch"][
|
||||
"requestBody"
|
||||
]["content"]["application/json"]["schema"]
|
||||
member_patch: Final = spec["components"]["schemas"][request_schema["$ref"].rsplit("/", 1)[-1]]
|
||||
parent_teams: Final = member_patch["properties"]["parent_team_ids"]
|
||||
assert parent_teams["type"] == "array"
|
||||
assert parent_teams["items"] == {"type": "string"}
|
||||
assert "parent_team_ids" not in member_patch.get("required", ())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("premium", (False, True))
|
||||
def test_member_team_replacement_checks_only_added_team_capacity(premium: bool) -> None:
|
||||
http: Final = client(premium_user=premium)
|
||||
family(http)
|
||||
assert (
|
||||
http.post(
|
||||
"/management/v1/customer_teams", json={"customer_team_id": "next", "parent_org_id": "org"}
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
for index in range(5):
|
||||
assert (
|
||||
http.post(
|
||||
"/management/v1/customer_members",
|
||||
json={"customer_member_id": f"full-{index}", "parent_team_ids": ["team"]},
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
moved: Final = http.patch("/management/v1/customer_members/full-0", json={"parent_team_ids": ["team", "next"]})
|
||||
assert moved.status_code == 200, moved.text
|
||||
reordered: Final = http.patch("/management/v1/customer_members/full-0", json={"parent_team_ids": ["next", "team"]})
|
||||
assert reordered.status_code == 200, reordered.text
|
||||
assert reordered.json()["parent_team_ids"] == ["next", "team"]
|
||||
assert (
|
||||
http.post(
|
||||
"/management/v1/customer_members", json={"customer_member_id": "moving", "parent_team_ids": ["next"]}
|
||||
).status_code
|
||||
== 201
|
||||
)
|
||||
full: Final = http.patch("/management/v1/customer_members/moving", json={"parent_team_ids": ["team"]})
|
||||
assert full.status_code == (200 if premium else 403), full.text
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue