diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py index e26dd993e26..6e836ad440c 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -19,8 +19,8 @@ from litellm.proxy._types import ( TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields" -# TODO(LIT-5722): stays empty until each field's value-diff and dashboard wiring lands, one field per PR -SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset() +# 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"}) _FIELD_LIST: Final = TypeAdapter(list[str]) _JSON_OBJECT: Final = TypeAdapter(dict[str, object]) diff --git a/tests/proxy_behavior/management/test_team_update.py b/tests/proxy_behavior/management/test_team_update.py index d6273e64132..eaf4e88e24b 100644 --- a/tests/proxy_behavior/management/test_team_update.py +++ b/tests/proxy_behavior/management/test_team_update.py @@ -13,7 +13,7 @@ pytestmark = pytest.mark.asyncio(loop_scope="session") # update_team and denials are the handler's 403, never the route gate's 401. # Only PROXY_ADMIN and an ORG_ADMIN of the team's org pass: a team admin is # admitted by _resolve_team_access but then refused because no team field is -# enabled for team admins (team_admin_editable_team_fields ships empty). +# enabled for team admins (team_admin_editable_team_fields defaults to empty). MARKER_ALIAS = "behavior-pin-update-marker-alias" _MATRIX = [ diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index db4b4bb7cf3..6cece466943 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -80,8 +80,8 @@ client = TestClient(app) def _team_admin_may_edit(*fields: str): """Let team admins change ``fields`` on /team/update for the duration of the block. - The registry ships empty (LIT-5722 adds fields one PR at a time), so tests that exercise the - gates layered underneath the allow-list widen it here instead of asserting the early 403.""" + The registry only lists the fields shipped so far (LIT-5722 adds them one PR at a time), so tests that + exercise the gates layered underneath the allow-list widen it here instead of asserting the early 403.""" with ( patch( # test-quality-ok: the registry is a module constant update_team reads directly; no seam to inject "litellm.proxy.management_endpoints.team_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS", @@ -15048,6 +15048,36 @@ async def test_update_team_team_admin_echoing_unpermitted_fields_unchanged_is_al assert prisma.db.litellm_teamtable.update.called +@pytest.mark.asyncio +async def test_update_team_team_admin_changes_tpm_limit_once_a_proxy_admin_enables_it( + disable_audit_logging_for_mocked_team, +): + """tpm_limit is the first field a proxy admin can open to team admins; every other field stays admin-only.""" + import contextlib + + with contextlib.ExitStack() as stack: + prisma = _wire_update_team(stack, {}) + stack.enter_context( + patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["tpm_limit"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global + ) + await update_team( + data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=5000), + http_request=_update_request_stub(), + user_api_key_dict=_TEAM_ADMIN_CALLER, + ) + with pytest.raises(ProxyException) as refused: + await update_team( + data=UpdateTeamRequest(team_id="test_team_id", tpm_limit=6000, rpm_limit=10), + http_request=_update_request_stub(), + user_api_key_dict=_TEAM_ADMIN_CALLER, + ) + + assert prisma.db.litellm_teamtable.update.await_count == 1 + assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 5000 + assert str(refused.value.code) == "403" + assert "'rpm_limit'" in str(refused.value.message) + + @pytest.mark.asyncio async def test_update_team_org_admin_is_not_filtered_by_the_team_admin_field_list( disable_audit_logging_for_mocked_team, diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index bff2428d0b6..06df39ede99 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3321,11 +3321,7 @@ class TestTeamAdminEditableTeamFieldsSetting: def test_patch_persists_and_syncs_the_list_to_general_settings(self, monkeypatch): mock_prisma = self._as_proxy_admin(monkeypatch) - monkeypatch.setattr( - "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS", - frozenset({"tpm_limit", "rpm_limit"}), - ) - general_settings: dict = {"team_admin_editable_team_fields": ["rpm_limit"]} + general_settings: dict = {"team_admin_editable_team_fields": []} monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings) try: @@ -3373,7 +3369,7 @@ class TestTeamAdminEditableTeamFieldsSetting: field_schema = data["field_schema"]["properties"]["team_admin_editable_team_fields"] assert field_schema["type"] == "array" assert field_schema["items"]["type"] == "string" - assert isinstance(field_schema["items"]["enum"], list) + assert "tpm_limit" in field_schema["items"]["enum"] class TestSyncUiSettingsToGeneralSettings: diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx index d1a44460190..3880acfb6d5 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx @@ -21,7 +21,7 @@ describe("TeamAdminEditableFieldsSettings", () => { expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); }); - it("renders one checkbox per supported field, checked for the enabled ones", () => { + it("renders one checkbox per supported field, checked for the enabled ones and named by the field's form label when the dashboard has one", () => { renderWithProviders( { expect(screen.getByText("1 field enabled")).toBeInTheDocument(); expect(screen.getByText("Fields a team admin may change")).toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "max_budget" })).not.toBeChecked(); - expect(screen.getByRole("checkbox", { name: "tpm_limit" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" })).toBeChecked(); }); it("saves the list with the field added when an unchecked field is ticked", async () => { @@ -67,7 +67,7 @@ describe("TeamAdminEditableFieldsSettings", () => { />, ); - await user.click(screen.getByRole("checkbox", { name: "tpm_limit" })); + await user.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" })); expect(onUpdate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] }); }); @@ -84,7 +84,7 @@ describe("TeamAdminEditableFieldsSettings", () => { />, ); - await user.click(screen.getByRole("checkbox", { name: "tpm_limit" })); + await user.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" })); expect(onUpdate).not.toHaveBeenCalled(); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.tsx index 8a42835ac4e..27f53e5fa77 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.tsx @@ -2,6 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { teamAdminFieldLabel } from "@/components/team/teamAdminEditAccess"; interface TeamAdminEditableFieldsSettingsProps { editableFields: readonly string[]; @@ -53,7 +54,7 @@ export default function TeamAdminEditableFieldsSettings({ disabled={isUpdating} onCheckedChange={(checked) => toggleField(field, checked === true)} /> - {field} + {teamAdminFieldLabel(field)} ); })} diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index 26d193cf5d8..f38caf0df81 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -187,7 +187,7 @@ describe("UISettings", () => { expect(screen.getByText("Team settings fields a team admin may change")).toBeInTheDocument(); act(() => { - fireEvent.click(screen.getByRole("checkbox", { name: "tpm_limit" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Tokens per minute Limit (TPM)" })); }); expect(mutateMock).toHaveBeenCalledWith( diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx new file mode 100644 index 00000000000..65f7a13652d --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; + +import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils"; + +import TeamAdminSettingsForm from "./TeamAdminSettingsForm"; + +const renderForm = (editableFields: ReadonlySet, overrides: { isSaving?: boolean } = {}) => { + const onSave = vi.fn().mockResolvedValue(undefined); + const onCancel = vi.fn(); + renderWithProviders( + , + ); + return { onSave, onCancel }; +}; + +describe("TeamAdminSettingsForm", () => { + it("shows the team's current TPM limit when the proxy lets team admins edit it", () => { + renderForm(new Set(["tpm_limit"])); + + expect(screen.getByLabelText("Tokens per minute Limit (TPM)")).toHaveValue(1000); + }); + + it("hides the TPM limit when the proxy has not enabled it for team admins", () => { + renderForm(new Set(["max_budget"])); + + expect(screen.queryByLabelText("Tokens per minute Limit (TPM)")).not.toBeInTheDocument(); + }); + + it("saves the new TPM limit and nothing else", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm(new Set(["tpm_limit"])); + + fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "5000" } }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: 5000 })); + }); + + it("saves a cleared TPM limit as no limit", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm(new Set(["tpm_limit"])); + + fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "" } }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tpm_limit: null })); + }); + + it("closes without saving on cancel", async () => { + const user = userEvent.setup(); + const { onSave, onCancel } = renderForm(new Set(["tpm_limit"])); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("locks both buttons while a save is in flight", () => { + renderForm(new Set(["tpm_limit"]), { isSaving: true }); + + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx new file mode 100644 index 00000000000..581215b7fdf --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Save } from "lucide-react"; +import { z } from "zod/v4"; + +import { FormField } from "@/components/shared/form/FormField"; +import { Button } from "@/components/ui/button"; +import { FieldGroup } from "@/components/ui/field"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { useZodForm } from "@/lib/forms/useZodForm"; + +import NumericalInput from "../shared/numerical_input"; +import { + teamAdminFieldLabel, + teamAdminSettingsChanges, + type TeamAdminSettingsChanges, + type TeamAdminSettingsValues, +} from "./teamAdminEditAccess"; + +const teamAdminSettingsSchema = z.object({ + tpm_limit: z.union([z.string(), z.number()]).nullish(), +}); + +interface TeamAdminSettingsFormProps { + initialValues: TeamAdminSettingsValues; + editableFields: ReadonlySet; + isSaving: boolean; + onCancel: () => void; + onSave: (changes: TeamAdminSettingsChanges) => Promise; +} + +export default function TeamAdminSettingsForm({ + initialValues, + editableFields, + isSaving, + onCancel, + onSave, +}: TeamAdminSettingsFormProps) { + const form = useZodForm(teamAdminSettingsSchema, { defaultValues: initialValues }); + const submit = form.handleSubmit((values) => onSave(teamAdminSettingsChanges(values, editableFields))); + + return ( +
void submit(event)}> + +

+ A proxy admin chose which settings team admins can change. Ask a proxy admin to change anything else. +

+ {editableFields.has("tpm_limit") && ( + + {({ ref, value, ...field }) => } + + )} +
+ +
+ + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index dea53eb42a2..e954bc1c581 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -1889,18 +1889,32 @@ describe("TeamInfoView", () => { expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); }); - it("opens the form for a team admin once the proxy reports an enabled field", async () => { + it("gives a team admin only the fields the proxy enabled and sends only those on save", async () => { const user = userEvent.setup({ delay: null }); vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] } }), + createMockTeamData({ + tpm_limit: 1000, + caller_edit_access: { kind: "team_admin", editable_fields: ["tpm_limit"] }, + }), ); + vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any); renderWithProviders(); await user.click(await screen.findByRole("tab", { name: "Settings" })); await user.click(await screen.findByRole("button", { name: /edit settings/i })); - expect(await screen.findByLabelText("Team Name")).toBeInTheDocument(); + const tpmInput = await screen.findByLabelText("Tokens per minute Limit (TPM)"); + expect(tpmInput).toHaveValue(1000); + expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Requests per minute Limit (RPM)")).not.toBeInTheDocument(); + + fireEvent.change(tpmInput, { target: { value: "5000" } }); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalledTimes(1)); + expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toStrictEqual({ team_id: "123", tpm_limit: 5000 }); + expect(toast.success).toHaveBeenCalledWith("Team settings updated successfully"); expect(toast.error).not.toHaveBeenCalled(); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 48de90f74b5..30b648fc53c 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -53,7 +53,9 @@ import { parseTeamEditAccess, TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION, TEAM_ADMIN_EDITING_DISABLED_TITLE, + type TeamAdminSettingsChanges, } from "./teamAdminEditAccess"; +import TeamAdminSettingsForm from "./TeamAdminSettingsForm"; import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils"; import AccessGroupSelector from "../common_components/AccessGroupSelector"; import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown"; @@ -862,6 +864,27 @@ const TeamInfoView: React.FC = ({ setMemberToDelete(null); }; + const persistTeamUpdate = async (token: string, updateData: Record) => { + await teamUpdateCall(token, updateData); + queryClient.invalidateQueries({ queryKey: organizationKeys.all }); + + toast.success("Team settings updated successfully"); + setIsEditing(false); + fetchTeamInfo(); + }; + + const saveTeamAdminSettings = async (changes: TeamAdminSettingsChanges) => { + if (!accessToken) return; + setIsTeamSaving(true); + try { + await persistTeamUpdate(accessToken, { team_id: teamId, ...changes }); + } catch (error) { + console.error("Error updating team:", error); + } finally { + setIsTeamSaving(false); + } + }; + const handleTeamUpdate = async (values: any) => { try { if (!accessToken) return; @@ -1112,12 +1135,7 @@ const TeamInfoView: React.FC = ({ } } - await teamUpdateCall(accessToken, updateData); - queryClient.invalidateQueries({ queryKey: organizationKeys.all }); - - toast.success("Team settings updated successfully"); - setIsEditing(false); - fetchTeamInfo(); + await persistTeamUpdate(accessToken, updateData); } catch (error) { console.error("Error updating team:", error); } finally { @@ -1135,6 +1153,17 @@ const TeamInfoView: React.FC = ({ const { team_info: info } = teamData; + const teamAdminSettingsEditor = + teamEditAccess.kind === "team_admin" ? ( + setIsEditing(false)} + onSave={saveTeamAdminSettings} + /> + ) : null; + const inheritedMcpServers = computeInheritedGrants( info.access_group_mcp_server_ids, info.access_group_details, @@ -1347,8 +1376,8 @@ const TeamInfoView: React.FC = ({ )} - {isEditing && isGuardrailsLoading ? ( -
Loading...
+ {isEditing && (teamAdminSettingsEditor !== null || isGuardrailsLoading) ? ( + teamAdminSettingsEditor ??
Loading...
) : isEditing ? (
void form.handleSubmit(onTeamUpdateSubmit)(event)}> diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts index 122cc749e6b..c8117800053 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts @@ -4,8 +4,40 @@ import { parseSupportedTeamAdminEditableFields, parseTeamAdminEditableFields, parseTeamEditAccess, + teamAdminFieldLabel, + teamAdminSettingsChanges, } 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("falls back to the raw field name for a field the dashboard has no label for", () => { + expect(teamAdminFieldLabel("max_budget")).toBe("max_budget"); + }); +}); + +describe("teamAdminSettingsChanges", () => { + const tpmEnabled = new Set(["tpm_limit"]); + + it.each([ + ["a typed number string", "5000", 5000], + ["a stored number", 1200, 1200], + ["zero", "0", 0], + ["an emptied input", "", null], + ["whitespace", " ", null], + ["no stored limit", null, null], + ["an unset value", undefined, null], + ])("sends tpm_limit for %s", (_label, tpm_limit, expected) => { + expect(teamAdminSettingsChanges({ tpm_limit }, tpmEnabled)).toStrictEqual({ tpm_limit: expected }); + }); + + it("leaves tpm_limit out when the proxy did not enable it for team admins", () => { + expect(teamAdminSettingsChanges({ tpm_limit: "5000" }, new Set(["max_budget"]))).toStrictEqual({}); + }); +}); + describe("parseTeamAdminEditableFields", () => { it("returns the configured list", () => { expect(parseTeamAdminEditableFields({ team_admin_editable_team_fields: ["tpm_limit", "rpm_limit"] })).toEqual([ diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts index 79cc81b1416..d38566eefda 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts @@ -39,6 +39,29 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk return items.success ? fieldListSchema.parse(items.data.enum) : []; }; +const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([["tpm_limit", "Tokens per minute Limit (TPM)"]]); + +export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field; + +export interface TeamAdminSettingsValues { + readonly tpm_limit?: string | number | null; +} + +export interface TeamAdminSettingsChanges { + readonly tpm_limit?: number | null; +} + +const numberOrNull = (value: string | number | null | undefined): number | null => { + if (value === null || value === undefined || String(value).trim() === "") return null; + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +}; + +export const teamAdminSettingsChanges = ( + values: TeamAdminSettingsValues, + editableFields: ReadonlySet, +): TeamAdminSettingsChanges => (editableFields.has("tpm_limit") ? { tpm_limit: numberOrNull(values.tpm_limit) } : {}); + export const parseTeamEditAccess = (callerEditAccess: unknown): TeamEditAccess => { const parsed = callerEditAccessSchema.safeParse(callerEditAccess); if (!parsed.success) return { kind: "none" };