mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(proxy): let team admins edit rpm_limit and max_budget when enabled
Adds both fields to the team admin editable allow-list and the dashboard's team admin form. The existing budget authority check still stops a team admin from raising or removing a standalone team's budget.
This commit is contained in:
parent
a36d2de5c9
commit
37c56df054
10 changed files with 184 additions and 32 deletions
|
|
@ -20,7 +20,7 @@ from litellm.proxy._types import (
|
|||
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields"
|
||||
|
||||
# TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit"})
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit", "rpm_limit", "max_budget"})
|
||||
|
||||
_FIELD_LIST: Final = TypeAdapter(list[str])
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
- {id: mgmt.team.update.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1582", rationale: "Metadata/budget updates persist"}
|
||||
- {id: mgmt.team.update.team_admin_forbidden_until_enabled, module: mgmt, tier: P0, surface: api, assertions: [team_admin_forbidden_until_enabled], source: "team_admin_field_permissions.py:156", rationale: "With no team admin editable fields enabled, a team admin's /team/update is 403 and /team/info reports editing disabled"}
|
||||
- {id: mgmt.team.update.team_admin_limited_to_enabled_fields, module: mgmt, tier: P0, surface: api, assertions: [team_admin_limited_to_enabled_fields], source: "team_admin_field_permissions.py:156", rationale: "A team admin may change only the enabled fields; a request that also changes any other field is 403 and writes nothing"}
|
||||
- {id: mgmt.team.update.team_admin_cannot_grow_budget, module: mgmt, tier: P0, surface: api, assertions: [team_admin_cannot_grow_budget], source: "team_endpoints.py:1203", rationale: "With max_budget enabled, a team admin may keep or lower a standalone team's budget; raising or removing it is 403 and writes nothing"}
|
||||
- {id: mgmt.team.update.team_admin_resend_keeps_budget_reset, module: mgmt, tier: P1, surface: api, assertions: [team_admin_resend_keeps_budget_reset], source: "team_admin_field_permissions.py:147", fail_before_fix: proven, rationale: "A team admin resending unchanged budget settings with an enabled field must not push the team's budget reset times back"}
|
||||
- {id: mgmt.team.delete.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py:1750", rationale: "Deletion prevents key access"}
|
||||
- {id: mgmt.team.block.persists, module: mgmt, tier: P1, surface: api, assertions: [persists], source: "team_endpoints.py", rationale: "Block suspends all members"}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pytestmark = pytest.mark.e2e
|
|||
TeamRole = Literal["admin", "user"]
|
||||
|
||||
_TEAM_TPM_LIMIT: Final = 1000
|
||||
_TEAM_MAX_BUDGET: Final = 10.0
|
||||
|
||||
|
||||
class TeamBlockBody(BaseModel):
|
||||
|
|
@ -114,6 +115,7 @@ class TeamInfoRead(BaseModel):
|
|||
|
||||
class TeamWithAdminNewBody(TeamNewBody):
|
||||
tpm_limit: int
|
||||
max_budget: float | None = None
|
||||
members_with_roles: list[TeamMemberEntry]
|
||||
|
||||
|
||||
|
|
@ -414,13 +416,22 @@ def tpm_limit_editable_by_team_admins(client: ManagementClient) -> Generator[Non
|
|||
yield
|
||||
|
||||
|
||||
def _team_with_admin(client: ManagementClient, resources: ResourceManager) -> tuple[str, str]:
|
||||
@pytest.fixture(scope="class")
|
||||
def rpm_limit_and_max_budget_editable_by_team_admins(client: ManagementClient) -> Generator[None]:
|
||||
with _team_admins_may_edit(client, ["rpm_limit", "max_budget"]):
|
||||
yield
|
||||
|
||||
|
||||
def _team_with_admin(
|
||||
client: ManagementClient, resources: ResourceManager, max_budget: float | None = None
|
||||
) -> tuple[str, str]:
|
||||
"""A team with a tpm_limit, and the key of a user who is an admin of that team."""
|
||||
admin_id = _create_user(client, resources, f"e2e-team-admin-{unique_marker()}@example.com")
|
||||
team_id = client.create_team(
|
||||
TeamWithAdminNewBody(
|
||||
team_alias=f"e2e-team-admin-{unique_marker()}",
|
||||
tpm_limit=_TEAM_TPM_LIMIT,
|
||||
max_budget=max_budget,
|
||||
members_with_roles=[TeamMemberEntry(role="admin", user_id=admin_id)],
|
||||
)
|
||||
)
|
||||
|
|
@ -580,3 +591,66 @@ class TestTeamAdminWithTpmLimitEnabled:
|
|||
assert after.budget_limits == budgeted.budget_limits, (
|
||||
f"the team admin pushed the budget window resets from {budgeted.budget_limits} to {after.budget_limits}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("rpm_limit_and_max_budget_editable_by_team_admins")
|
||||
class TestTeamAdminWithRpmLimitAndMaxBudgetEnabled:
|
||||
"""A proxy admin has enabled rpm_limit and max_budget, so a team admin may change the RPM limit and keep or
|
||||
lower the team's budget. Raising or removing the budget stays with the proxy admin."""
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_limited_to_enabled_fields")
|
||||
def test_team_admin_saves_a_new_rpm_limit_and_a_lower_budget(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET)
|
||||
access = _read_team(client, team_id, admin_key).team_info.caller_edit_access
|
||||
assert access == CallerEditAccess(kind="team_admin", editable_fields=["max_budget", "rpm_limit"]), (
|
||||
f"/team/info should list max_budget and rpm_limit as the team admin's editable fields, got {access}"
|
||||
)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client, admin_key, TeamSettingsUpdate(team_id=team_id, rpm_limit=50, max_budget=_TEAM_MAX_BUDGET / 2)
|
||||
)
|
||||
|
||||
assert outcome.status_code == 200, (
|
||||
f"a team admin setting an RPM limit and lowering the budget must succeed, got {outcome.status_code}: "
|
||||
f"{outcome.body[:300]}"
|
||||
)
|
||||
after = _poll_team(
|
||||
client,
|
||||
team_id,
|
||||
lambda info: info.rpm_limit == 50 and info.max_budget == _TEAM_MAX_BUDGET / 2,
|
||||
f"/team/info never reflected rpm_limit=50 and max_budget={_TEAM_MAX_BUDGET / 2}",
|
||||
)
|
||||
assert after.model_copy(update={"rpm_limit": before.rpm_limit, "max_budget": before.max_budget}) == before, (
|
||||
f"the update changed more than rpm_limit and max_budget: before {before}, after {after}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.team.update.team_admin_cannot_grow_budget")
|
||||
@pytest.mark.parametrize(
|
||||
("max_budget", "refusal"),
|
||||
[
|
||||
pytest.param(_TEAM_MAX_BUDGET * 2, "Only a proxy admin can raise", id="raise"),
|
||||
pytest.param(None, "Only a proxy admin can remove", id="remove"),
|
||||
],
|
||||
)
|
||||
def test_team_admin_cannot_raise_or_remove_the_budget(
|
||||
self, client: ManagementClient, resources: ResourceManager, max_budget: float | None, refusal: str
|
||||
) -> None:
|
||||
team_id, admin_key = _team_with_admin(client, resources, max_budget=_TEAM_MAX_BUDGET)
|
||||
before = _read_team(client, team_id).team_info
|
||||
|
||||
outcome = _update_team_as(
|
||||
client, admin_key, TeamSettingsUpdate(team_id=team_id, rpm_limit=50, max_budget=max_budget)
|
||||
)
|
||||
|
||||
assert outcome.status_code == 403, (
|
||||
f"a team admin changing max_budget from {_TEAM_MAX_BUDGET} to {max_budget} must be 403, "
|
||||
f"got {outcome.status_code}: {outcome.body[:300]}"
|
||||
)
|
||||
assert refusal in outcome.body, f"403 body should say {refusal!r}, got: {outcome.body[:300]}"
|
||||
after = _read_team(client, team_id).team_info
|
||||
assert after == before, (
|
||||
f"the refused update still wrote to the team, the rpm_limit included: before {before}, after {after}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3324,15 +3324,17 @@ class TestTeamAdminEditableTeamFieldsSetting:
|
|||
general_settings: dict = {"team_admin_editable_team_fields": []}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
|
||||
|
||||
enabled = ["tpm_limit", "rpm_limit", "max_budget"]
|
||||
|
||||
try:
|
||||
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["tpm_limit"]})
|
||||
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": enabled})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
|
||||
assert stored["team_admin_editable_team_fields"] == ["tpm_limit"]
|
||||
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
|
||||
assert stored["team_admin_editable_team_fields"] == enabled
|
||||
assert general_settings["team_admin_editable_team_fields"] == enabled
|
||||
|
||||
def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch):
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?:
|
|||
const onCancel = vi.fn();
|
||||
renderWithProviders(
|
||||
<TeamAdminSettingsForm
|
||||
initialValues={{ tpm_limit: 1000 }}
|
||||
initialValues={{ tpm_limit: 1000, rpm_limit: 50, max_budget: 20 }}
|
||||
editableFields={editableFields}
|
||||
isSaving={overrides.isSaving ?? false}
|
||||
onCancel={onCancel}
|
||||
|
|
@ -21,16 +21,20 @@ const renderForm = (editableFields: ReadonlySet<string>, overrides: { isSaving?:
|
|||
};
|
||||
|
||||
describe("TeamAdminSettingsForm", () => {
|
||||
it("shows the team's current TPM limit when the proxy lets team admins edit it", () => {
|
||||
renderForm(new Set(["tpm_limit"]));
|
||||
it("shows the team's current values for every field the proxy lets team admins edit", () => {
|
||||
renderForm(new Set(["tpm_limit", "rpm_limit", "max_budget"]));
|
||||
|
||||
expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toHaveValue(1000);
|
||||
expect(screen.getByLabelText("Requests per minute Limit (RPM)")).toHaveValue(50);
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(20);
|
||||
});
|
||||
|
||||
it("hides the TPM limit when the proxy has not enabled it for team admins", () => {
|
||||
renderForm(new Set(["max_budget"]));
|
||||
it("hides the fields the proxy has not enabled for team admins", () => {
|
||||
renderForm(new Set(["rpm_limit"]));
|
||||
|
||||
expect(screen.getByLabelText("Requests per minute Limit (RPM)")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Tokens per minute Limit (TPM)")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Max Budget (USD)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the new TPM limit and nothing else", async () => {
|
||||
|
|
@ -43,6 +47,17 @@ describe("TeamAdminSettingsForm", () => {
|
|||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: 5000 }));
|
||||
});
|
||||
|
||||
it("saves a lowered budget and a new RPM limit without resending the unchanged TPM limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = renderForm(new Set(["tpm_limit", "rpm_limit", "max_budget"]));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Requests per minute Limit (RPM)"), { target: { value: "80" } });
|
||||
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "12.5" } });
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ rpm_limit: 80, max_budget: 12.5 }));
|
||||
});
|
||||
|
||||
it("saves a cleared TPM limit as no limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSave } = renderForm(new Set(["tpm_limit"]));
|
||||
|
|
|
|||
|
|
@ -12,16 +12,24 @@ import { useZodForm } from "@/lib/forms/useZodForm";
|
|||
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
import {
|
||||
TEAM_ADMIN_SETTINGS_FIELDS,
|
||||
teamAdminFieldLabel,
|
||||
teamAdminSettingsChanges,
|
||||
type TeamAdminSettingsChanges,
|
||||
type TeamAdminSettingsField,
|
||||
type TeamAdminSettingsValues,
|
||||
} from "./teamAdminEditAccess";
|
||||
|
||||
const numericInputSchema = z.union([z.string(), z.number()]).nullish();
|
||||
|
||||
const teamAdminSettingsSchema = z.object({
|
||||
tpm_limit: z.union([z.string(), z.number()]).nullish(),
|
||||
tpm_limit: numericInputSchema,
|
||||
rpm_limit: numericInputSchema,
|
||||
max_budget: numericInputSchema,
|
||||
});
|
||||
|
||||
const INPUT_STEP: Readonly<Record<TeamAdminSettingsField, number>> = { tpm_limit: 1, rpm_limit: 1, max_budget: 0.01 };
|
||||
|
||||
interface TeamAdminSettingsFormProps {
|
||||
initialValues: TeamAdminSettingsValues;
|
||||
editableFields: ReadonlySet<string>;
|
||||
|
|
@ -48,11 +56,13 @@ export default function TeamAdminSettingsForm({
|
|||
<p className="text-sm text-muted-foreground">
|
||||
A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else.
|
||||
</p>
|
||||
{editableFields.has("tpm_limit") && (
|
||||
<FormField control={form.control} name="tpm_limit" label={teamAdminFieldLabel("tpm_limit")}>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} ref={ref} value={value ?? ""} step={1} />}
|
||||
{TEAM_ADMIN_SETTINGS_FIELDS.filter((name) => editableFields.has(name)).map((name) => (
|
||||
<FormField key={name} control={form.control} name={name} label={teamAdminFieldLabel(name)}>
|
||||
{({ ref, value, ...field }) => (
|
||||
<NumericalInput {...field} ref={ref} value={value ?? ""} step={INPUT_STEP[name]} />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
))}
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -1918,6 +1918,26 @@ describe("TeamInfoView", () => {
|
|||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefills the RPM limit and budget a team admin may edit with the team's stored values", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({
|
||||
rpm_limit: 50,
|
||||
max_budget: 20,
|
||||
caller_edit_access: { kind: "team_admin", editable_fields: ["rpm_limit", "max_budget"] },
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Settings" }));
|
||||
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
|
||||
|
||||
expect(await screen.findByLabelText("Requests per minute Limit (RPM)")).toHaveValue(50);
|
||||
expect(screen.getByLabelText("Max Budget (USD)")).toHaveValue(20);
|
||||
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
|
|
|
|||
|
|
@ -1156,7 +1156,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
const teamAdminSettingsEditor =
|
||||
teamEditAccess.kind === "team_admin" ? (
|
||||
<TeamAdminSettingsForm
|
||||
initialValues={{ tpm_limit: info.tpm_limit }}
|
||||
initialValues={{ tpm_limit: info.tpm_limit, rpm_limit: info.rpm_limit, max_budget: info.max_budget }}
|
||||
editableFields={teamEditAccess.editableFields}
|
||||
isSaving={isTeamSaving}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,16 @@ import {
|
|||
} from "./teamAdminEditAccess";
|
||||
|
||||
describe("teamAdminFieldLabel", () => {
|
||||
it("names tpm_limit the way the team settings form does", () => {
|
||||
expect(teamAdminFieldLabel("tpm_limit")).toBe("Tokens per minute Limit (TPM)");
|
||||
it.each([
|
||||
["tpm_limit", "Tokens per minute Limit (TPM)"],
|
||||
["rpm_limit", "Requests per minute Limit (RPM)"],
|
||||
["max_budget", "Max Budget (USD)"],
|
||||
])("names %s the way the team settings form does", (field, label) => {
|
||||
expect(teamAdminFieldLabel(field)).toBe(label);
|
||||
});
|
||||
|
||||
it("falls back to the raw field name for a field the dashboard has no label for", () => {
|
||||
expect(teamAdminFieldLabel("max_budget")).toBe("max_budget");
|
||||
expect(teamAdminFieldLabel("team_alias")).toBe("team_alias");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -46,6 +50,27 @@ describe("teamAdminSettingsChanges", () => {
|
|||
it("leaves tpm_limit out when the proxy did not enable it for team admins", () => {
|
||||
expect(teamAdminSettingsChanges({ tpm_limit: "5000" }, stored, new Set(["max_budget"]))).toStrictEqual({});
|
||||
});
|
||||
|
||||
const allStored = { tpm_limit: 1000, rpm_limit: 10, max_budget: 20 };
|
||||
|
||||
it("sends every enabled field that changed and skips the ones that did not", () => {
|
||||
const values = { tpm_limit: "1000", rpm_limit: "50", max_budget: "12.5" };
|
||||
const enabled = new Set(["tpm_limit", "rpm_limit", "max_budget"]);
|
||||
|
||||
expect(teamAdminSettingsChanges(values, allStored, enabled)).toStrictEqual({ rpm_limit: 50, max_budget: 12.5 });
|
||||
});
|
||||
|
||||
it("sends a cleared max budget as no budget", () => {
|
||||
expect(teamAdminSettingsChanges({ max_budget: "" }, allStored, new Set(["max_budget"]))).toStrictEqual({
|
||||
max_budget: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves out changed fields the proxy did not enable", () => {
|
||||
const values = { tpm_limit: "5000", rpm_limit: "50", max_budget: "5" };
|
||||
|
||||
expect(teamAdminSettingsChanges(values, allStored, new Set(["rpm_limit"]))).toStrictEqual({ rpm_limit: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTeamAdminEditableFields", () => {
|
||||
|
|
|
|||
|
|
@ -39,17 +39,21 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk
|
|||
return items.success ? fieldListSchema.parse(items.data.enum) : [];
|
||||
};
|
||||
|
||||
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([["tpm_limit", "Tokens per minute Limit (TPM)"]]);
|
||||
export const TEAM_ADMIN_SETTINGS_FIELDS = ["tpm_limit", "rpm_limit", "max_budget"] as const;
|
||||
|
||||
export type TeamAdminSettingsField = (typeof TEAM_ADMIN_SETTINGS_FIELDS)[number];
|
||||
|
||||
const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
|
||||
["tpm_limit", "Tokens per minute Limit (TPM)"],
|
||||
["rpm_limit", "Requests per minute Limit (RPM)"],
|
||||
["max_budget", "Max Budget (USD)"],
|
||||
]);
|
||||
|
||||
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
|
||||
|
||||
export interface TeamAdminSettingsValues {
|
||||
readonly tpm_limit?: string | number | null;
|
||||
}
|
||||
export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: string | number | null };
|
||||
|
||||
export interface TeamAdminSettingsChanges {
|
||||
readonly tpm_limit?: number | null;
|
||||
}
|
||||
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null };
|
||||
|
||||
const numberOrNull = (value: string | number | null | undefined): number | null => {
|
||||
if (value === null || value === undefined || String(value).trim() === "") return null;
|
||||
|
|
@ -61,12 +65,13 @@ export const teamAdminSettingsChanges = (
|
|||
values: TeamAdminSettingsValues,
|
||||
initialValues: TeamAdminSettingsValues,
|
||||
editableFields: ReadonlySet<string>,
|
||||
): TeamAdminSettingsChanges => {
|
||||
const tpmLimit = numberOrNull(values.tpm_limit);
|
||||
return editableFields.has("tpm_limit") && tpmLimit !== numberOrNull(initialValues.tpm_limit)
|
||||
? { tpm_limit: tpmLimit }
|
||||
: {};
|
||||
};
|
||||
): TeamAdminSettingsChanges =>
|
||||
Object.fromEntries(
|
||||
TEAM_ADMIN_SETTINGS_FIELDS.flatMap((field) => {
|
||||
const value = numberOrNull(values[field]);
|
||||
return editableFields.has(field) && value !== numberOrNull(initialValues[field]) ? [[field, value]] : [];
|
||||
}),
|
||||
);
|
||||
|
||||
export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => {
|
||||
const parsed = callerEditAccessSchema.safeParse(callerEditAccess);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue