diff --git a/litellm/ui/litellm-dashboard/src/components/key_info_utils.test.tsx b/litellm/ui/litellm-dashboard/src/components/key_info_utils.test.tsx index 490e188e21a..2f73eb4b3df 100644 --- a/litellm/ui/litellm-dashboard/src/components/key_info_utils.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/key_info_utils.test.tsx @@ -4,6 +4,7 @@ import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata, + stripFieldManagedKeysFromMetadata, } from "./key_info_utils"; describe("filterSensitiveMetadata", () => { @@ -57,3 +58,15 @@ describe("stripTagsFromMetadata", () => { expect(input).toEqual(originalCopy); }); }); + +describe("stripFieldManagedKeysFromMetadata", () => { + it.each(["ops@qa.test", ["ops@qa.test"], 80])( + "leaves a max_budget_alert_emails value that is not a threshold map in the Metadata box: %j", + (value) => { + expect(stripFieldManagedKeysFromMetadata({ cost_center: "ops", max_budget_alert_emails: value })).toEqual({ + cost_center: "ops", + max_budget_alert_emails: value, + }); + }, + ); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/key_info_utils.tsx b/litellm/ui/litellm-dashboard/src/components/key_info_utils.tsx index e346878c24e..2f573a8e84e 100644 --- a/litellm/ui/litellm-dashboard/src/components/key_info_utils.tsx +++ b/litellm/ui/litellm-dashboard/src/components/key_info_utils.tsx @@ -2,6 +2,8 @@ * Utility functions for handling key information and metadata */ +import { budgetAlertEmailsMetadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY } from "./key_team_helpers/budgetAlertEmails"; + // Fields that should be filtered out from metadata display due to security concerns const SENSITIVE_METADATA_FIELDS = ["logging"] as const; @@ -61,3 +63,14 @@ export const stripTagsFromMetadata = (metadata: any) => { const { tags, ...rest } = metadata as Record; return rest; }; + +export const stripFieldManagedKeysFromMetadata = ( + metadata: Record | null | undefined, +): Record | null | undefined => { + const withoutTags: Record | null | undefined = stripTagsFromMetadata(metadata); + if (!withoutTags || typeof withoutTags !== "object") { + return withoutTags; + } + const { [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _maxBudgetAlertEmails, ...rest } = withoutTags; + return { ...rest, ...budgetAlertEmailsMetadata({}, KEY_MAX_BUDGET_ALERT_EMAILS_KEY, withoutTags) }; +}; diff --git a/litellm/ui/litellm-dashboard/src/components/key_team_helpers/BudgetAlertEmailsEditor.tsx b/litellm/ui/litellm-dashboard/src/components/key_team_helpers/BudgetAlertEmailsEditor.tsx new file mode 100644 index 00000000000..87d40a55b4b --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/key_team_helpers/BudgetAlertEmailsEditor.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { type ArrayPath, type Control, type FieldPath, type FieldValues, useFieldArray } from "react-hook-form"; +import { CircleMinus, Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { FormField } from "@/components/shared/form/FormField"; +import NumericalInput from "../shared/numerical_input"; +import type { BudgetAlertRow } from "./budgetAlertEmails"; + +export interface BudgetAlertEmailsEditorText { + readonly thresholdPlaceholder: string; + readonly recipientsPlaceholder: string; + readonly removeThreshold: string; + readonly addThreshold: string; +} + +type RowsPath = string extends keyof TFieldValues ? string : ArrayPath; + +interface BudgetAlertEmailsEditorProps { + readonly control: Control; + readonly name: RowsPath; + readonly label: React.ReactNode; + readonly text: BudgetAlertEmailsEditorText; +} + +const EMPTY_ROW: BudgetAlertRow = { threshold: null, emails: "" }; + +export const BudgetAlertEmailsEditor = ({ + control, + name, + label, + text, +}: BudgetAlertEmailsEditorProps) => { + const { fields, append, remove } = useFieldArray({ control, name: name as ArrayPath }); + const rowField = (index: number, column: keyof BudgetAlertRow) => + `${name}.${index}.${column}` as FieldPath; + + return ( + + {label} + {fields.map((row, index) => ( +
+ + {({ ref, value, onChange, ...field }) => ( + ) => + onChange(event.target.value === "" ? null : Number(event.target.value)) + } + placeholder={text.thresholdPlaceholder} + min={1} + max={100} + step={1} + /> + )} + + + {({ ref, value, ...field }) => ( + + )} + + +
+ ))} + +
+ ); +}; diff --git a/litellm/ui/litellm-dashboard/src/components/key_team_helpers/budgetAlertEmails.test.ts b/litellm/ui/litellm-dashboard/src/components/key_team_helpers/budgetAlertEmails.test.ts new file mode 100644 index 00000000000..23e372fff80 --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/key_team_helpers/budgetAlertEmails.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + budgetAlertEmailsFromRows, + budgetAlertEmailsMetadata, + budgetAlertEmailsRowsCanShow, + budgetAlertRowsFromMetadata, + isValidThreshold, + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, + TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY, + teamMemberBudgetAlertSummary, +} from "./budgetAlertEmails"; + +describe("budgetAlertRowsFromMetadata", () => { + it("turns the stored threshold map into rows sorted by threshold", () => { + const metadata = { + team_member_max_budget_alert_emails: { "100": ["finance@example.com", "cto@example.com"], "50": [] }, + }; + expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 50, emails: "" }, + { threshold: 100, emails: "finance@example.com, cto@example.com" }, + ]); + }); + + it("drops non-numeric thresholds and non-email recipients instead of crashing", () => { + const metadata = { + team_member_max_budget_alert_emails: { fifty: [], "90": { a: 1 }, "100": ["a@b.c"] }, + }; + expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 100, emails: "a@b.c" }, + ]); + }); + + it("loads a null recipient list as an owner-only row and keeps the strings of a mixed list", () => { + const metadata = { max_budget_alert_emails: { "75": null, "80": ["b@example.com", null, 3] } }; + expect(budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 75, emails: "" }, + { threshold: 80, emails: "b@example.com" }, + ]); + }); + + it("lists API-stored string recipients in the team member summary", () => { + expect(teamMemberBudgetAlertSummary({ team_member_max_budget_alert_emails: { "80": "a@x.com, b@x.com" } })).toEqual( + ["80%: member, a@x.com, b@x.com"], + ); + }); + + it("leaves a zero-padded threshold to the Metadata box so it never duplicates a row", () => { + const metadata = { max_budget_alert_emails: { "080": ["padded@example.com"], "80": ["plain@example.com"] } }; + expect(budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 80, emails: "plain@example.com" }, + ]); + }); + + it("still loads a zero-padded team member threshold as a row, since the team form has no Metadata box fallback", () => { + const metadata = { team_member_max_budget_alert_emails: { "080": ["finance@example.com"] } }; + expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 80, emails: "finance@example.com" }, + ]); + }); + + it("keeps API-stored comma-separated string recipients, which the backend also accepts", () => { + const metadata = { + max_budget_alert_emails: { "80": "finance@example.com, cto@example.com", "90": ["ops@example.com"] }, + }; + const rows = budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY); + expect(rows).toEqual([ + { threshold: 80, emails: "finance@example.com, cto@example.com" }, + { threshold: 90, emails: "ops@example.com" }, + ]); + expect(budgetAlertEmailsFromRows(rows)).toEqual({ + "80": ["finance@example.com", "cto@example.com"], + "90": ["ops@example.com"], + }); + }); + + it("drops API-stored thresholds outside 1 to 100 so they never block the form", () => { + const metadata = { + team_member_max_budget_alert_emails: { "0": ["a@b.c"], "50": [], "101": ["a@b.c"] }, + }; + expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 50, emails: "" }, + ]); + }); + + it.each([undefined, null, "50", { team_member_max_budget_alert_emails: "50" }, { soft_budget_alerting_emails: [] }])( + "returns no rows for unrelated or malformed metadata %j", + (metadata) => { + expect(budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([]); + }, + ); + + it("reads only the metadata key it is asked for", () => { + const metadata = { + team_member_max_budget_alert_emails: { "50": ["team@example.com"] }, + max_budget_alert_emails: { "80": ["shared-mailbox@example.com"] }, + }; + expect(budgetAlertRowsFromMetadata(metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).toEqual([ + { threshold: 80, emails: "shared-mailbox@example.com" }, + ]); + }); +}); + +describe("budgetAlertEmailsMetadata", () => { + it("writes the threshold map under the given metadata key", () => { + expect( + budgetAlertEmailsMetadata( + budgetAlertEmailsFromRows([{ threshold: 80, emails: "dl@example.com" }]), + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, + {}, + ), + ).toEqual({ max_budget_alert_emails: { "80": ["dl@example.com"] } }); + }); + + it.each([{ rows: [] }, { rows: [{ threshold: null, emails: "dl@example.com" }] }])( + "leaves the key out when no row has a valid threshold $rows", + ({ rows }) => { + expect(budgetAlertEmailsMetadata(budgetAlertEmailsFromRows(rows), KEY_MAX_BUDGET_ALERT_EMAILS_KEY, {})).toEqual( + {}, + ); + }, + ); + + it("keeps the entries the rows cannot show and lets the rows own every threshold they can", () => { + const boxMetadata = { + max_budget_alert_emails: { + "50": ["old@example.com"], + "80": ["removed@example.com"], + "90": null, + "150": ["over@example.com"], + "0": ["zero@example.com"], + "12.5": ["fraction@example.com"], + soon: "label@example.com", + }, + }; + + expect( + budgetAlertEmailsMetadata( + budgetAlertEmailsFromRows([{ threshold: 50, emails: "new@example.com" }]), + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, + boxMetadata, + ), + ).toEqual({ + max_budget_alert_emails: { + "50": ["new@example.com"], + "150": ["over@example.com"], + "0": ["zero@example.com"], + "12.5": ["fraction@example.com"], + soon: "label@example.com", + }, + }); + }); +}); + +describe("budgetAlertEmailsRowsCanShow", () => { + it("rebuilds the stored map together with the entries the rows cannot show, values untouched", () => { + const stored = { + max_budget_alert_emails: { + "50": "a@example.com, b@example.com", + "75": null, + "80": ["c@example.com", null], + "150": ["over@example.com"], + }, + }; + + expect( + budgetAlertEmailsMetadata( + budgetAlertEmailsRowsCanShow(stored, KEY_MAX_BUDGET_ALERT_EMAILS_KEY), + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, + stored, + ), + ).toEqual(stored); + expect(budgetAlertEmailsRowsCanShow(stored, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)).not.toHaveProperty("150"); + }); +}); + +describe("budgetAlertEmailsFromRows", () => { + it("builds the threshold map, splitting, trimming and deduplicating recipients", () => { + expect( + budgetAlertEmailsFromRows([ + { threshold: 50, emails: "" }, + { threshold: 100, emails: " finance@example.com,cto@example.com , finance@example.com, " }, + ]), + ).toEqual({ "50": [], "100": ["finance@example.com", "cto@example.com"] }); + }); + + it("skips rows without a valid threshold", () => { + expect( + budgetAlertEmailsFromRows([ + { threshold: null, emails: "finance@example.com" }, + { threshold: 0, emails: "" }, + { threshold: 101, emails: "" }, + { threshold: 12.5, emails: "" }, + { threshold: 80, emails: "" }, + ]), + ).toEqual({ "80": [] }); + }); + + it("round-trips the stored config", () => { + const stored = { team_member_max_budget_alert_emails: { "50": [], "100": ["finance@example.com"] } }; + expect( + budgetAlertEmailsFromRows(budgetAlertRowsFromMetadata(stored, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY)), + ).toEqual(stored.team_member_max_budget_alert_emails); + }); +}); + +describe("isValidThreshold", () => { + it.each([ + [1, true], + [50, true], + [100, true], + [0, false], + [101, false], + [33.3, false], + [null, false], + ])("treats %s as valid=%s", (threshold, valid) => { + expect(isValidThreshold(threshold)).toBe(valid); + }); +}); + +describe("teamMemberBudgetAlertSummary", () => { + it("states that the member is always notified and lists extra recipients", () => { + expect( + teamMemberBudgetAlertSummary({ + team_member_max_budget_alert_emails: { "100": ["finance@example.com"], "50": [] }, + }), + ).toEqual(["50%: member", "100%: member, finance@example.com"]); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/key_team_helpers/budgetAlertEmails.ts b/litellm/ui/litellm-dashboard/src/components/key_team_helpers/budgetAlertEmails.ts new file mode 100644 index 00000000000..b8f7ba4020e --- /dev/null +++ b/litellm/ui/litellm-dashboard/src/components/key_team_helpers/budgetAlertEmails.ts @@ -0,0 +1,143 @@ +import { z } from "zod"; + +export const TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY = "team_member_max_budget_alert_emails" as const; +export const KEY_MAX_BUDGET_ALERT_EMAILS_KEY = "max_budget_alert_emails" as const; + +export type BudgetAlertEmailsMetadataKey = + | typeof TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY + | typeof KEY_MAX_BUDGET_ALERT_EMAILS_KEY; + +export interface BudgetAlertRow { + readonly threshold: number | null; + readonly emails: string; +} + +export type BudgetAlertEmails = Readonly>; + +export type BudgetAlertRowIssue = "thresholdRange" | "duplicateThreshold"; + +const splitEmails = (emails: string): readonly string[] => + Array.from( + new Set( + emails + .split(",") + .map((email) => email.trim()) + .filter((email) => email.length > 0), + ), + ); + +const storedRecipients = (value: unknown): readonly string[] | null => { + if (value === null) return []; + if (typeof value === "string") return splitEmails(value); + if (Array.isArray(value)) return value.filter((email): email is string => typeof email === "string"); + return null; +}; + +const THRESHOLD_MIN = 1; +const THRESHOLD_MAX = 100; + +export const isValidThreshold = (threshold: number | null): threshold is number => { + const isWholeNumber = threshold !== null && Number.isInteger(threshold); + return isWholeNumber && threshold >= THRESHOLD_MIN && threshold <= THRESHOLD_MAX; +}; + +const isBudgetAlertMap = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const storedBudgetAlertValue = (metadata: unknown, metadataKey: BudgetAlertEmailsMetadataKey): unknown => + typeof metadata === "object" && metadata !== null ? (metadata as Record)[metadataKey] : undefined; + +const storedBudgetAlertConfig = ( + metadata: unknown, + metadataKey: BudgetAlertEmailsMetadataKey, +): Readonly> => { + const config = storedBudgetAlertValue(metadata, metadataKey); + return isBudgetAlertMap(config) ? config : {}; +}; + +const ROW_THRESHOLD_KEY_PATTERN: Readonly> = { + [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: /^[1-9]\d*$/, + [TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY]: /^\d+$/, +}; + +const budgetAlertRowFromEntry = ( + [key, emails]: readonly [string, unknown], + metadataKey: BudgetAlertEmailsMetadataKey, +): BudgetAlertRow | null => { + const threshold = Number(key); + const recipients = storedRecipients(emails); + return ROW_THRESHOLD_KEY_PATTERN[metadataKey].test(key) && isValidThreshold(threshold) && recipients !== null + ? { threshold, emails: recipients.join(", ") } + : null; +}; + +export const budgetAlertRowsFromMetadata = ( + metadata: unknown, + metadataKey: BudgetAlertEmailsMetadataKey, +): readonly BudgetAlertRow[] => + Object.entries(storedBudgetAlertConfig(metadata, metadataKey)) + .flatMap((entry) => { + const row = budgetAlertRowFromEntry(entry, metadataKey); + return row === null ? [] : [row]; + }) + .sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0)); + +const storedBudgetAlertEntries = ( + metadata: unknown, + metadataKey: BudgetAlertEmailsMetadataKey, + rowsCanShow: boolean, +): Readonly> => + Object.fromEntries( + Object.entries(storedBudgetAlertConfig(metadata, metadataKey)).filter( + (entry) => (budgetAlertRowFromEntry(entry, metadataKey) !== null) === rowsCanShow, + ), + ); + +export const budgetAlertEmailsRowsCanShow = ( + metadata: unknown, + metadataKey: BudgetAlertEmailsMetadataKey, +): Readonly> => storedBudgetAlertEntries(metadata, metadataKey, true); + +export const budgetAlertEmailsRowsCannotShow = ( + metadata: unknown, + metadataKey: BudgetAlertEmailsMetadataKey, +): Readonly> => storedBudgetAlertEntries(metadata, metadataKey, false); + +export const budgetAlertEmailsFromRows = (rows: readonly BudgetAlertRow[]): BudgetAlertEmails => + Object.fromEntries( + rows + .filter((row) => isValidThreshold(row.threshold)) + .map((row) => [String(row.threshold), splitEmails(row.emails)]), + ); + +export const budgetAlertEmailsMetadata = ( + rowEntries: Readonly>, + metadataKey: BudgetAlertEmailsMetadataKey, + metadataWithHiddenEntries: unknown, +): Readonly>> => { + const boxValue = storedBudgetAlertValue(metadataWithHiddenEntries, metadataKey); + if (Object.keys(rowEntries).length === 0 && boxValue !== undefined && !isBudgetAlertMap(boxValue)) { + return { [metadataKey]: boxValue }; + } + const emails = { + ...budgetAlertEmailsRowsCannotShow(metadataWithHiddenEntries, metadataKey), + ...rowEntries, + }; + return Object.keys(emails).length > 0 ? { [metadataKey]: emails } : {}; +}; + +export const budgetAlertRowsSchema = (message: (issue: BudgetAlertRowIssue) => string) => + z.array(z.object({ threshold: z.number().nullable(), emails: z.string() })).superRefine((rows, ctx) => { + rows.forEach((row, index) => { + if (!isValidThreshold(row.threshold)) { + ctx.addIssue({ code: "custom", message: message("thresholdRange"), path: [index, "threshold"] }); + } else if (rows.filter((other) => other.threshold === row.threshold).length > 1) { + ctx.addIssue({ code: "custom", message: message("duplicateThreshold"), path: [index, "threshold"] }); + } + }); + }); + +export const teamMemberBudgetAlertSummary = (metadata: unknown): readonly string[] => + budgetAlertRowsFromMetadata(metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY).map((row) => + row.emails.length > 0 ? `${row.threshold}%: member, ${row.emails}` : `${row.threshold}%: member`, + ); diff --git a/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts b/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts index c544a89930d..6104da1cf65 100644 --- a/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts +++ b/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.test.ts @@ -29,6 +29,78 @@ it.each([false, true])("preserves the prompt storage restriction %s in key metad expect(JSON.parse(String(result.payload.metadata))).toEqual({ note: "keep", store_prompts_in_spend_logs: store }); }); +it("merges the budget alert rows into the metadata JSON box content", () => { + const result = build( + { + metadata: JSON.stringify({ cost_center: "ops" }), + max_budget_alert_emails: [ + { threshold: 80, emails: "shared-mailbox@example.com" }, + { threshold: null, emails: "ignored@example.com" }, + ], + }, + { disabledCallbacks: ["langfuse"] }, + ); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") throw new Error(result.kind); + expect(result.payload).not.toHaveProperty("max_budget_alert_emails"); + expect(JSON.parse(String(result.payload.metadata))).toEqual({ + cost_center: "ops", + litellm_disabled_callbacks: ["langfuse"], + max_budget_alert_emails: { "80": ["shared-mailbox@example.com"] }, + }); +}); + +it("keeps typed recipient entries the rows cannot show and lets the rows own every threshold they can", () => { + const result = build({ + metadata: JSON.stringify({ + cost_center: "ops", + max_budget_alert_emails: { "500": "typed@example.com", "80": ["stale@example.com"] }, + }), + max_budget_alert_emails: [{ threshold: 50, emails: "row@example.com" }], + }); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") throw new Error(result.kind); + expect(JSON.parse(String(result.payload.metadata))).toEqual({ + cost_center: "ops", + max_budget_alert_emails: { "500": "typed@example.com", "50": ["row@example.com"] }, + }); +}); + +it("stores a typed max_budget_alert_emails string as typed when no row is set", () => { + const result = build({ metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }) }); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") throw new Error(result.kind); + expect(JSON.parse(String(result.payload.metadata))).toEqual({ + cost_center: "ops", + max_budget_alert_emails: "ops@qa.test", + }); +}); + +it("replaces a typed max_budget_alert_emails string with the threshold rows", () => { + const result = build({ + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }), + max_budget_alert_emails: [{ threshold: 80, emails: "row@example.com" }], + }); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") throw new Error(result.kind); + expect(JSON.parse(String(result.payload.metadata))).toEqual({ + cost_center: "ops", + max_budget_alert_emails: { "80": ["row@example.com"] }, + }); +}); + +it("keeps the budget alert rows when the metadata JSON box holds null", () => { + const result = build({ + metadata: "null", + max_budget_alert_emails: [{ threshold: 80, emails: "shared-mailbox@example.com" }], + }); + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") throw new Error(result.kind); + expect(JSON.parse(String(result.payload.metadata))).toEqual({ + max_budget_alert_emails: { "80": ["shared-mailbox@example.com"] }, + }); +}); + const payloadOf = (result: KeyPayloadResult): Record => { expect(result.kind).toBe("ok"); if (result.kind !== "ok") throw new Error("unreachable"); diff --git a/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts b/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts index 311e9357825..4cd2e35a2fa 100644 --- a/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts +++ b/litellm/ui/litellm-dashboard/src/components/organisms/createKeyPayload.ts @@ -1,6 +1,12 @@ import { mapDisplayToInternalNames } from "../callback_info_helpers"; import { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown"; import type { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion"; +import { + type BudgetAlertRow, + budgetAlertEmailsFromRows, + budgetAlertEmailsMetadata, + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, +} from "../key_team_helpers/budgetAlertEmails"; import type { BudgetWindowEntry } from "../key_team_helpers/BudgetWindowsEditor"; import type { ModelMaxBudget } from "../key_team_helpers/ModelMaxBudgetEditor"; import { tagRowsToLimits, type TagRateLimitEntry } from "../key_team_helpers/TagRateLimitEditor"; @@ -103,7 +109,18 @@ const buildMetadataJson = (values: Record, input: KeyCreateInpu input.disabledCallbacks.length > 0 ? { ...(logged as object), litellm_disabled_callbacks: mapDisplayToInternalNames(input.disabledCallbacks) } : logged; - return JSON.stringify(disabled); + const metadataObject = disabled ?? {}; + const alertEmails = budgetAlertEmailsMetadata( + budgetAlertEmailsFromRows((values[KEY_MAX_BUDGET_ALERT_EMAILS_KEY] as readonly BudgetAlertRow[] | undefined) ?? []), + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, + metadataObject, + ); + if (typeof metadataObject !== "object" || Array.isArray(metadataObject)) return JSON.stringify(metadataObject); + const { [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _typedAlertEmails, ...withoutTypedAlertEmails } = metadataObject as Record< + string, + unknown + >; + return JSON.stringify({ ...withoutTypedAlertEmails, ...alertEmails }); }; interface PermissionSources { @@ -153,6 +170,7 @@ const consumedSourceKeys = ( new Set([ "mcp_tool_permissions", "allowed_skills", + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, ...(values.disable_global_guardrails ? [] : ["disable_global_guardrails"]), ...(vectorStores ? ["allowed_vector_store_ids"] : []), ...(mcp ? ["allowed_mcp_servers_and_groups"] : []), diff --git a/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx index bbc849167cc..89cdb51d234 100644 --- a/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; +import { act, fireEvent, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; import type { Team } from "../key_team_helpers/key_list"; import { keyCreateCall, keyCreateServiceAccountCall, modelAvailableCall, userFilterUICall } from "../networking"; import { toast } from "@/lib/toast"; @@ -593,6 +593,68 @@ describe("CreateKey", () => { expect((await createdPayload()).metadata).toBe('{"team":"research"}'); }); + const addBudgetAlertRow = async (threshold: string, emails: string) => { + await userEvent.click(await screen.findByRole("button", { name: "Add Budget Alert Threshold" })); + const thresholds = screen.getAllByPlaceholderText("% of budget"); + fireEvent.change(thresholds[thresholds.length - 1], { target: { value: threshold } }); + const recipients = screen.getAllByPlaceholderText("Additional recipients, e.g. finance@example.com"); + fireEvent.change(recipients[recipients.length - 1], { target: { value: emails } }); + }; + + it("merges the budget alert rows into the metadata JSON box content", async () => { + await openModal(); + await nameTheKey(); + await openSection(/Optional Settings/i); + fireEvent.change(await screen.findByLabelText("Metadata"), { target: { value: '{"team":"research"}' } }); + await addBudgetAlertRow("80", "shared-mailbox@example.com, finance-dl@example.com"); + await submit(); + + const payload = await createdPayload(); + expect(JSON.parse(String(payload.metadata))).toEqual({ + team: "research", + max_budget_alert_emails: { "80": ["shared-mailbox@example.com", "finance-dl@example.com"] }, + }); + expect(payload).not.toHaveProperty("max_budget_alert_emails"); + }); + + it("moves a recipient map typed into the metadata JSON box into the rows on blur", async () => { + vi.mocked(toast.info).mockClear(); + await openModal(); + await nameTheKey(); + await openSection(/Optional Settings/i); + await addBudgetAlertRow("80", "row@example.com"); + const metadataBox = await screen.findByLabelText("Metadata"); + fireEvent.change(metadataBox, { + target: { value: JSON.stringify({ team: "research", max_budget_alert_emails: { "80": "typed@example.com" } }) }, + }); + fireEvent.blur(metadataBox); + + expect(toast.info).toHaveBeenCalledWith( + "Moved alert thresholds 80% from metadata to the Budget Alert Thresholds rows", + ); + expect(screen.getByPlaceholderText("Additional recipients, e.g. finance@example.com")).toHaveValue( + "typed@example.com", + ); + await submit(); + + expect(JSON.parse(String((await createdPayload()).metadata))).toEqual({ + team: "research", + max_budget_alert_emails: { "80": ["typed@example.com"] }, + }); + }); + + it("blocks the create on a duplicate budget alert threshold", async () => { + await openModal(); + await nameTheKey(); + await openSection(/Optional Settings/i); + await addBudgetAlertRow("80", "a@example.com"); + await addBudgetAlertRow("80", "b@example.com"); + await submit(); + + expect(await screen.findAllByText("Duplicate threshold")).toHaveLength(2); + expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled(); + }); + it("carries the typed key alias and the chosen team onto the wire", async () => { state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }]; await openModal({ teams: state.teams as unknown as Team[] }); diff --git a/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 2e82e1ca4f5..cc2b75d10a0 100644 --- a/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/litellm/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -42,6 +42,7 @@ import { MountedFormField, MountedFormProvider, projectMountedValues, + useMountedName, useMountRegistry, type MountedFormValues, } from "../common_components/MountedFormField"; @@ -56,6 +57,13 @@ import TeamDropdown from "../common_components/team_dropdown"; import OrganizationDropdown from "../common_components/OrganizationDropdown"; import ProjectDropdown from "../common_components/ProjectDropdown"; import { CreateUserButton } from "../CreateUserButton"; +import { moveMetadataBudgetAlertsToRows } from "../templates/KeyEditViewControls"; +import { BudgetAlertEmailsEditor, type BudgetAlertEmailsEditorText } from "../key_team_helpers/BudgetAlertEmailsEditor"; +import { + type BudgetAlertRow, + budgetAlertRowsSchema, + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, +} from "../key_team_helpers/budgetAlertEmails"; import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor"; import { END_USER_BUDGET_HINT, EndUserBudgetSelect } from "../key_team_helpers/EndUserBudgetSelect"; import { BudgetWindowEntry, BudgetWindowsEditor } from "../key_team_helpers/BudgetWindowsEditor"; @@ -132,6 +140,15 @@ type McpSelectorValue = { servers: string[]; accessGroups: string[]; toolsets?: type AgentSelectorValue = { agents: string[]; accessGroups: string[] }; +const MountedBudgetAlertEmailsEditor = (props: { + readonly control: Control; + readonly label: React.ReactNode; + readonly text: BudgetAlertEmailsEditorText; +}) => { + useMountedName(KEY_MAX_BUDGET_ALERT_EMAILS_KEY); + return ; +}; + const isBlank = (value: unknown): boolean => value === undefined || value === null || value === ""; const requiredRule = (required: boolean, message: string) => ({ @@ -532,7 +549,21 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp }; const handleSubmit = (event: React.FormEvent) => - void form.handleSubmit(() => handleCreate(projectMountedValues(registry, form.getValues)))(event); + void form.handleSubmit(() => { + const values = projectMountedValues(registry, form.getValues); + const alertRows = budgetAlertRowsSchema((issue) => + issue === "thresholdRange" + ? t("keyDetail.maxBudgetAlertEmails.thresholdRange") + : t("keyDetail.maxBudgetAlertEmails.duplicateThreshold"), + ).safeParse(values[KEY_MAX_BUDGET_ALERT_EMAILS_KEY] ?? []); + if (!alertRows.success) { + alertRows.error.issues.forEach((issue) => + form.setError([KEY_MAX_BUDGET_ALERT_EMAILS_KEY, ...issue.path].join("."), { message: issue.message }), + ); + return; + } + return handleCreate(values); + })(event); // Fetch available models when team or auth changes. // Note: Model prefill from URL params is handled by the useEffect below, which @@ -1093,6 +1124,25 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp /> )} +
+ + {t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholds")}{" "} + + + + + } + text={{ + thresholdPlaceholder: t("keyDetail.maxBudgetAlertEmails.thresholdPlaceholder"), + recipientsPlaceholder: t("keyDetail.maxBudgetAlertEmails.recipientsPlaceholder"), + removeThreshold: t("keyDetail.maxBudgetAlertEmails.removeThreshold"), + addThreshold: t("keyDetail.maxBudgetAlertEmails.addThreshold"), + }} + /> +
= ({ team, teams, data, addKey, autoOp value={(control.value as string | undefined) ?? ""} rows={4} placeholder={t("createKey.metadata.placeholder")} + onBlur={() => { + control.onBlur(); + moveMetadataBudgetAlertsToRows( + form.getValues("metadata") as string | undefined, + form.getValues(KEY_MAX_BUDGET_ALERT_EMAILS_KEY) as BudgetAlertRow[] | undefined, + (metadata, rows) => { + form.setValue("metadata", metadata, { shouldDirty: true }); + form.setValue(KEY_MAX_BUDGET_ALERT_EMAILS_KEY, rows, { shouldDirty: true }); + }, + t, + ); + }} /> )} diff --git a/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 24e6dd75abf..ad4c88dd75f 100644 --- a/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/litellm/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -130,12 +130,13 @@ import { import TeamMembersComponent from "./TeamMemberTab"; import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useModels"; import { - isValidThreshold, + budgetAlertEmailsFromRows, + budgetAlertRowsFromMetadata, + budgetAlertRowsSchema, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY, - teamMemberBudgetAlertEmailsFromRows, - teamMemberBudgetAlertRowsFromMetadata, teamMemberBudgetAlertSummary, -} from "./teamMemberBudgetAlertEmails"; +} from "../key_team_helpers/budgetAlertEmails"; +import { BudgetAlertEmailsEditor } from "../key_team_helpers/BudgetAlertEmailsEditor"; import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog"; import { customBudgetMemberUserIds, shouldPromptMemberBudgetReset } from "./memberBudgetReset"; @@ -387,26 +388,11 @@ const buildTeamUpdateFieldsSchema = (t: TFunction) => team_member_key_duration: z.string().optional(), team_member_tpm_limit: numericInputSchema, team_member_rpm_limit: numericInputSchema, - team_member_max_budget_alert_emails: z - .array(z.object({ threshold: z.number().nullable(), emails: z.string() })) - .superRefine((rows, ctx) => { - rows.forEach((row, index) => { - if (!isValidThreshold(row.threshold)) { - ctx.addIssue({ - code: "custom", - message: t("teams.teamInfo.validation.thresholdRange"), - path: [index, "threshold"], - }); - } else if (rows.filter((other) => other.threshold === row.threshold).length > 1) { - ctx.addIssue({ - code: "custom", - message: t("teams.teamInfo.validation.duplicateThreshold"), - path: [index, "threshold"], - }); - } - }); - }) - .optional(), + team_member_max_budget_alert_emails: budgetAlertRowsSchema((issue) => + issue === "thresholdRange" + ? t("teams.teamInfo.validation.thresholdRange") + : t("teams.teamInfo.validation.duplicateThreshold"), + ).optional(), budget_duration: z.string().nullish(), tpm_limit: numericInputSchema, rpm_limit: numericInputSchema, @@ -551,7 +537,9 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]): team_member_key_duration: info.metadata?.team_member_key_duration, team_member_tpm_limit: info.team_member_budget_table?.tpm_limit, team_member_rpm_limit: info.team_member_budget_table?.rpm_limit, - team_member_max_budget_alert_emails: [...teamMemberBudgetAlertRowsFromMetadata(info.metadata)], + team_member_max_budget_alert_emails: [ + ...budgetAlertRowsFromMetadata(info.metadata, TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY), + ], budget_duration: info.budget_duration, tpm_limit: info.tpm_limit, rpm_limit: info.rpm_limit, @@ -639,11 +627,6 @@ const TeamInfoView: React.FC = ({ append: appendModelLimit, remove: removeModelLimit, } = useFieldArray({ control: form.control, name: "modelLimits" }); - const { - fields: memberBudgetAlertRows, - append: appendMemberBudgetAlertRow, - remove: removeMemberBudgetAlertRow, - } = useFieldArray({ control: form.control, name: "team_member_max_budget_alert_emails" }); const [teamMemberSettingsOpen, setTeamMemberSettingsOpen] = useState(false); const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false); const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); @@ -1081,7 +1064,7 @@ const TeamInfoView: React.FC = ({ const memberBudgetAlertEmails = values.team_member_max_budget_alert_emails !== undefined - ? teamMemberBudgetAlertEmailsFromRows(values.team_member_max_budget_alert_emails) + ? budgetAlertEmailsFromRows(values.team_member_max_budget_alert_emails) : info.metadata?.[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY]; const memberBudgetAlertEmailsMetadata = memberBudgetAlertEmails !== undefined && Object.keys(memberBudgetAlertEmails).length > 0 @@ -1877,71 +1860,22 @@ const TeamInfoView: React.FC = ({ /> )} - - - {labelWithHint( - t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholds"), - t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholdsHint"), - )} - - {memberBudgetAlertRows.map((row, index) => ( -
- - {({ ref, value, onChange, ...field }) => ( - ) => - onChange(event.target.value === "" ? null : Number(event.target.value)) - } - placeholder={t("teams.teamInfo.editForm.teamMemberSettings.thresholdPlaceholder")} - min={1} - max={100} - step={1} - /> - )} - - - {({ ref, value, ...field }) => ( - - )} - - -
- ))} - -
+ diff --git a/litellm/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts b/litellm/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts deleted file mode 100644 index 3faa051047b..00000000000 --- a/litellm/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isValidThreshold, - teamMemberBudgetAlertEmailsFromRows, - teamMemberBudgetAlertRowsFromMetadata, - teamMemberBudgetAlertSummary, -} from "./teamMemberBudgetAlertEmails"; - -describe("teamMemberBudgetAlertRowsFromMetadata", () => { - it("turns the stored threshold map into rows sorted by threshold", () => { - const metadata = { - team_member_max_budget_alert_emails: { "100": ["finance@example.com", "cto@example.com"], "50": [] }, - }; - expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([ - { threshold: 50, emails: "" }, - { threshold: 100, emails: "finance@example.com, cto@example.com" }, - ]); - }); - - it("drops non-numeric thresholds and non-list recipients instead of crashing", () => { - const metadata = { - team_member_max_budget_alert_emails: { fifty: [], "75": "finance@example.com", "90": [1], "100": ["a@b.c"] }, - }; - expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([{ threshold: 100, emails: "a@b.c" }]); - }); - - it("drops API-stored thresholds outside 1 to 100 so they never block the form", () => { - const metadata = { - team_member_max_budget_alert_emails: { "0": ["a@b.c"], "50": [], "101": ["a@b.c"] }, - }; - expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([{ threshold: 50, emails: "" }]); - }); - - it.each([undefined, null, "50", { team_member_max_budget_alert_emails: "50" }, { soft_budget_alerting_emails: [] }])( - "returns no rows for unrelated or malformed metadata %j", - (metadata) => { - expect(teamMemberBudgetAlertRowsFromMetadata(metadata)).toEqual([]); - }, - ); -}); - -describe("teamMemberBudgetAlertEmailsFromRows", () => { - it("builds the threshold map, splitting, trimming and deduplicating recipients", () => { - expect( - teamMemberBudgetAlertEmailsFromRows([ - { threshold: 50, emails: "" }, - { threshold: 100, emails: " finance@example.com,cto@example.com , finance@example.com, " }, - ]), - ).toEqual({ "50": [], "100": ["finance@example.com", "cto@example.com"] }); - }); - - it("skips rows without a valid threshold", () => { - expect( - teamMemberBudgetAlertEmailsFromRows([ - { threshold: null, emails: "finance@example.com" }, - { threshold: 0, emails: "" }, - { threshold: 101, emails: "" }, - { threshold: 12.5, emails: "" }, - { threshold: 80, emails: "" }, - ]), - ).toEqual({ "80": [] }); - }); - - it("round-trips the stored config", () => { - const stored = { team_member_max_budget_alert_emails: { "50": [], "100": ["finance@example.com"] } }; - expect(teamMemberBudgetAlertEmailsFromRows(teamMemberBudgetAlertRowsFromMetadata(stored))).toEqual( - stored.team_member_max_budget_alert_emails, - ); - }); -}); - -describe("isValidThreshold", () => { - it.each([ - [1, true], - [50, true], - [100, true], - [0, false], - [101, false], - [33.3, false], - [null, false], - ])("treats %s as valid=%s", (threshold, valid) => { - expect(isValidThreshold(threshold)).toBe(valid); - }); -}); - -describe("teamMemberBudgetAlertSummary", () => { - it("states that the member is always notified and lists extra recipients", () => { - expect( - teamMemberBudgetAlertSummary({ - team_member_max_budget_alert_emails: { "100": ["finance@example.com"], "50": [] }, - }), - ).toEqual(["50%: member", "100%: member, finance@example.com"]); - }); -}); diff --git a/litellm/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts b/litellm/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts deleted file mode 100644 index 36d5ddbac02..00000000000 --- a/litellm/ui/litellm-dashboard/src/components/team/teamMemberBudgetAlertEmails.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY = "team_member_max_budget_alert_emails" as const; - -export interface TeamMemberBudgetAlertRow { - readonly threshold: number | null; - readonly emails: string; -} - -export type TeamMemberBudgetAlertEmails = Readonly>; - -const isEmailList = (value: unknown): value is readonly string[] => - Array.isArray(value) && value.every((email) => typeof email === "string"); - -const splitEmails = (emails: string): readonly string[] => - Array.from( - new Set( - emails - .split(",") - .map((email) => email.trim()) - .filter((email) => email.length > 0), - ), - ); - -const THRESHOLD_MIN = 1; -const THRESHOLD_MAX = 100; - -export const isValidThreshold = (threshold: number | null): threshold is number => { - const isWholeNumber = threshold !== null && Number.isInteger(threshold); - return isWholeNumber && threshold >= THRESHOLD_MIN && threshold <= THRESHOLD_MAX; -}; - -export const teamMemberBudgetAlertRowsFromMetadata = (metadata: unknown): readonly TeamMemberBudgetAlertRow[] => { - if (typeof metadata !== "object" || metadata === null) return []; - const config: unknown = (metadata as Record)[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY]; - if (typeof config !== "object" || config === null || Array.isArray(config)) return []; - return Object.entries(config as Record) - .flatMap(([key, emails]) => { - const threshold = Number(key); - return /^\d+$/.test(key) && isValidThreshold(threshold) && isEmailList(emails) - ? [{ threshold, emails: emails.join(", ") }] - : []; - }) - .sort((a, b) => (a.threshold ?? 0) - (b.threshold ?? 0)); -}; - -export const teamMemberBudgetAlertEmailsFromRows = ( - rows: readonly TeamMemberBudgetAlertRow[], -): TeamMemberBudgetAlertEmails => - Object.fromEntries( - rows - .filter((row) => isValidThreshold(row.threshold)) - .map((row) => [String(row.threshold), splitEmails(row.emails)]), - ); - -export const teamMemberBudgetAlertSummary = (metadata: unknown): readonly string[] => - teamMemberBudgetAlertRowsFromMetadata(metadata).map((row) => - row.emails.length > 0 ? `${row.threshold}%: member, ${row.emails}` : `${row.threshold}%: member`, - ); diff --git a/litellm/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx b/litellm/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx index 3b96134f746..d44877a39e8 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/KeyEditViewControls.tsx @@ -14,7 +14,8 @@ import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; import { StorePromptsMetadataToggle } from "../common_components/StorePromptsToggle"; import NumericalInput from "../shared/numerical_input"; import SkillSelector from "../skills/SkillSelector"; -import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers"; +import type { BudgetAlertRow } from "../key_team_helpers/budgetAlertEmails"; +import { moveBudgetAlertEmailsOutOfMetadataJson, moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers"; import { AgentsAndGroups, KeyEditFormValues } from "./keyEditFormValues"; export const labelWithHint = (label: React.ReactNode, hint: string): React.ReactNode => ( @@ -186,8 +187,42 @@ export const moveMetadataTagsToTagsField = (form: KeyEditForm, t: TFunction): vo } }; -export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => { +export const moveMetadataBudgetAlertsToRows = ( + metadata: string | undefined, + rows: readonly BudgetAlertRow[] | undefined, + write: (metadata: string, rows: BudgetAlertRow[]) => void, + t: TFunction, +): void => { + const moved = moveBudgetAlertEmailsOutOfMetadataJson(metadata, rows); + if (moved === null) return; + write(moved.metadata, [...moved.rows]); + const thresholds = moved.movedThresholds.map((threshold) => `${threshold}%`).join(", "); + toast.info(t("keyDetail.maxBudgetAlertEmails.movedThresholds", { thresholds })); +}; + +export const moveMetadataBudgetAlertsToKeyRows = (form: KeyEditForm, t: TFunction): void => + moveMetadataBudgetAlertsToRows( + form.getValues("metadata"), + form.getValues("max_budget_alert_emails"), + (metadata, rows) => { + form.setValue("metadata", metadata, { shouldDirty: true }); + form.setValue("max_budget_alert_emails", rows, { shouldDirty: true }); + }, + t, + ); + +export const KeyMetadataField = ({ + form, + budgetAlertRowsShown, +}: { + form: KeyEditForm; + budgetAlertRowsShown: boolean; +}) => { const { t } = useTranslation(); + const moveTypedFieldsOutOfMetadata = () => { + moveMetadataTagsToTagsField(form, t); + if (budgetAlertRowsShown) moveMetadataBudgetAlertsToKeyRows(form, t); + }; return ( { rows={10} onBlur={() => { field.onBlur(); - moveMetadataTagsToTagsField(form, t); + moveTypedFieldsOutOfMetadata(); }} /> diff --git a/litellm/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/litellm/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 2c57445104f..4ddb5001a06 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -196,7 +196,8 @@ vi.mock("./key_edit_view", async () => { React.createElement( "button", { - onClick: () => props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}), + onClick: () => + props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}, (globalThis as any).__TEST_DIRTY_FIELDS ?? []), }, "Mock Submit", ), @@ -231,7 +232,7 @@ const baseKeyData = { next_rotation_at: null as any, }; -const renderView = (premiumUser: boolean) => { +const renderView = (premiumUser: boolean, keyData: Record = baseKeyData) => { // Configure the mock for this test mockUseAuthorized.mockReturnValue({ accessToken: "access_abc", @@ -245,19 +246,14 @@ const renderView = (premiumUser: boolean) => { }); return render( - {}} - keyData={baseKeyData as any} - onKeyDataUpdate={() => {}} - teams={[]} - />, + {}} keyData={keyData as any} onKeyDataUpdate={() => {}} teams={[]} />, ); }; beforeEach(() => { vi.clearAllMocks(); (globalThis as any).__TEST_FORM_VALUES = undefined; + (globalThis as any).__TEST_DIRTY_FIELDS = undefined; }); // ---- Tests ---- @@ -715,3 +711,171 @@ describe("KeyInfoView handleKeyUpdate organization", () => { expect(shownOrganization()).toHaveTextContent("org-1"); }); }); + +describe("KeyInfoView handleKeyUpdate max_budget_alert_emails", () => { + const submit = async ( + formValues: Record, + dirtyFields: readonly string[] = ["max_budget_alert_emails"], + keyData: Record = baseKeyData, + ) => { + renderView(true, keyData); + fireEvent.click(screen.getByText("Settings")); + fireEvent.click(screen.getByText("Edit Settings")); + Object.assign(globalThis, { + __TEST_FORM_VALUES: { token: "tok_123", ...formValues }, + __TEST_DIRTY_FIELDS: dirtyFields, + }); + fireEvent.click(screen.getByText("Mock Submit")); + await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled()); + return keyUpdateCallMock.mock.calls[0][1]; + }; + + it("writes the alert rows into metadata next to the JSON box content and the dedicated metadata fields", async () => { + const formValues = { + metadata: JSON.stringify({ cost_center: "ops", nested: { owner: "platform" } }), + tags: ["prod"], + guardrails: ["gr-1"], + logging_settings: [{ callback_name: "langfuse" }], + disabled_callbacks: ["langsmith"], + max_budget_alert_emails: [ + { threshold: 80, emails: "shared-mailbox@example.com, finance-dl@example.com" }, + { threshold: 50, emails: "" }, + ], + }; + const expectedMetadata = { + cost_center: "ops", + nested: { owner: "platform" }, + tags: ["prod"], + guardrails: ["gr-1"], + logging: [{ callback_name: "langfuse" }], + litellm_disabled_callbacks: ["langsmith"], + max_budget_alert_emails: { "80": ["shared-mailbox@example.com", "finance-dl@example.com"], "50": [] }, + }; + + const sentPayload = await submit(formValues); + + expect(sentPayload.metadata).toEqual(expectedMetadata); + expect(sentPayload).not.toHaveProperty("max_budget_alert_emails"); + }); + + it("clears the stored recipients when every row is removed, even if the JSON box still names them", async () => { + const sentPayload = await submit({ + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "80": ["old@example.com"] } }), + max_budget_alert_emails: [], + }); + + expect(sentPayload.metadata).toEqual({ cost_center: "ops" }); + }); + + it("writes the alert rows when the metadata JSON box is left empty", async () => { + const sentPayload = await submit({ + metadata: "", + max_budget_alert_emails: [{ threshold: 90, emails: "dl@example.com" }], + }); + + expect(sentPayload.metadata).toEqual({ max_budget_alert_emails: { "90": ["dl@example.com"] } }); + }); + + it("sends the stored map verbatim on a save that does not edit the rows", async () => { + const storedAlertEmails = { + "50": ["a@example.com"], + "75": null, + "80": ["b@example.com", null], + "150": ["c@example.com"], + }; + const sentPayload = await submit( + { + key_alias: "renamed", + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "150": ["c@example.com"] } }), + max_budget_alert_emails: [{ threshold: 50, emails: "a@example.com" }], + }, + ["key_alias"], + { ...baseKeyData, metadata: { cost_center: "ops", max_budget_alert_emails: storedAlertEmails } }, + ); + + expect(sentPayload.metadata).toEqual({ cost_center: "ops", max_budget_alert_emails: storedAlertEmails }); + }); + const KEY_WITH_HIDDEN_ALERT = { + ...baseKeyData, + metadata: { + cost_center: "ops", + max_budget_alert_emails: { + "50": ["old@example.com"], + "80": ["removed@example.com"], + "150": ["c@example.com"], + }, + }, + }; + + it("keeps the entry the rows cannot show that the JSON box still holds when the rows were edited", async () => { + const sentPayload = await submit( + { + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "150": ["c@example.com"] } }), + max_budget_alert_emails: [{ threshold: 50, emails: "new@example.com" }], + }, + ["max_budget_alert_emails"], + KEY_WITH_HIDDEN_ALERT, + ); + + expect(sentPayload.metadata).toEqual({ + cost_center: "ops", + max_budget_alert_emails: { "50": ["new@example.com"], "150": ["c@example.com"] }, + }); + }); + + it("keeps only the box's hidden entry when every row is removed", async () => { + const sentPayload = await submit( + { + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "150": ["c@example.com"] } }), + max_budget_alert_emails: [], + }, + ["max_budget_alert_emails"], + KEY_WITH_HIDDEN_ALERT, + ); + + expect(sentPayload.metadata).toEqual({ + cost_center: "ops", + max_budget_alert_emails: { "150": ["c@example.com"] }, + }); + }); + + it("clears the map when every row is removed and the hidden entry is deleted from the JSON box", async () => { + const sentPayload = await submit( + { metadata: JSON.stringify({ cost_center: "ops" }), max_budget_alert_emails: [] }, + ["max_budget_alert_emails"], + KEY_WITH_HIDDEN_ALERT, + ); + + expect(sentPayload.metadata).toEqual({ cost_center: "ops" }); + }); + + it("saves an entry the rows cannot show typed into the JSON box on an untouched-rows edit", async () => { + const sentPayload = await submit( + { + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "0": ["zero@example.com"] } }), + max_budget_alert_emails: [], + }, + ["metadata"], + { ...baseKeyData, metadata: { cost_center: "ops" } }, + ); + + expect(sentPayload.metadata).toEqual({ + cost_center: "ops", + max_budget_alert_emails: { "0": ["zero@example.com"] }, + }); + }); + + it("saves a stored max_budget_alert_emails string back unchanged on an unrelated edit", async () => { + const sentPayload = await submit( + { + key_alias: "renamed", + metadata: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }), + max_budget_alert_emails: [], + }, + ["key_alias"], + { ...baseKeyData, metadata: { cost_center: "ops", max_budget_alert_emails: "ops@qa.test" } }, + ); + + expect(sentPayload.metadata).toEqual({ cost_center: "ops", max_budget_alert_emails: "ops@qa.test" }); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts b/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts index 9223cc6dbc8..cbae3e67540 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts +++ b/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers"; +import { moveBudgetAlertEmailsOutOfMetadataJson, moveTagsOutOfMetadataJson } from "./keyEditFieldNormalizers"; describe("moveTagsOutOfMetadataJson", () => { it("moves a tags array out of the JSON and appends it to the current tags", () => { @@ -48,3 +48,45 @@ describe("moveTagsOutOfMetadataJson", () => { expect(moveTagsOutOfMetadataJson("null", ["a"])).toBeNull(); }); }); + +describe("moveBudgetAlertEmailsOutOfMetadataJson", () => { + it("moves the typed thresholds into the rows, letting a typed entry replace the row with its threshold", () => { + const typed = JSON.stringify({ + env: "prod", + max_budget_alert_emails: { "80": "typed@example.com, dl@example.com", "90": null }, + }); + const rows = [ + { threshold: 50, emails: "kept@example.com" }, + { threshold: 80, emails: "replaced@example.com" }, + ]; + + expect(moveBudgetAlertEmailsOutOfMetadataJson(typed, rows)).toEqual({ + metadata: '{\n "env": "prod"\n}', + rows: [ + { threshold: 50, emails: "kept@example.com" }, + { threshold: 80, emails: "typed@example.com, dl@example.com" }, + { threshold: 90, emails: "" }, + ], + movedThresholds: [80, 90], + }); + }); + + it("leaves the typed entries the rows cannot show in the JSON", () => { + const typed = JSON.stringify({ max_budget_alert_emails: { "80": ["a@example.com"], "150": ["b@example.com"] } }); + + expect(moveBudgetAlertEmailsOutOfMetadataJson(typed, [])).toEqual({ + metadata: JSON.stringify({ max_budget_alert_emails: { "150": ["b@example.com"] } }, null, 2), + rows: [{ threshold: 80, emails: "a@example.com" }], + movedThresholds: [80], + }); + }); + + it("returns null when nothing typed can become a row", () => { + expect( + moveBudgetAlertEmailsOutOfMetadataJson('{"max_budget_alert_emails": {"150": ["b@example.com"]}}', []), + ).toBeNull(); + expect(moveBudgetAlertEmailsOutOfMetadataJson('{"env": "prod"}', [])).toBeNull(); + expect(moveBudgetAlertEmailsOutOfMetadataJson('{"max_budget_alert_emails": {', [])).toBeNull(); + expect(moveBudgetAlertEmailsOutOfMetadataJson(undefined, [])).toBeNull(); + }); +}); diff --git a/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts b/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts index 529bffb9a84..8dec643b747 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts +++ b/litellm/ui/litellm-dashboard/src/components/templates/keyEditFieldNormalizers.ts @@ -1,5 +1,11 @@ import type { TFunction } from "i18next"; import i18n from "@/i18n/i18n"; +import { + type BudgetAlertRow, + budgetAlertEmailsRowsCannotShow, + budgetAlertRowsFromMetadata, + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, +} from "../key_team_helpers/budgetAlertEmails"; const WORD_FORM_BUDGET_DURATIONS: Record = { hourly: "1h", @@ -75,6 +81,27 @@ export const moveTagsOutOfMetadataJson = ( return { metadata: JSON.stringify(rest, null, 2), tags: [...existing, ...movedTags], movedTags }; }; +export type MovedMetadataBudgetAlerts = { + readonly metadata: string; + readonly rows: readonly BudgetAlertRow[]; + readonly movedThresholds: readonly (number | null)[]; +}; + +export const moveBudgetAlertEmailsOutOfMetadataJson = ( + metadataJson: string | undefined, + currentRows: readonly BudgetAlertRow[] | undefined, +): MovedMetadataBudgetAlerts | null => { + const parsed = metadataJson === undefined ? null : parseJsonObject(metadataJson); + const typedRows = budgetAlertRowsFromMetadata(parsed, KEY_MAX_BUDGET_ALERT_EMAILS_KEY); + if (parsed === null || typedRows.length === 0) return null; + const movedThresholds = typedRows.map((row) => row.threshold); + const unmoved = budgetAlertEmailsRowsCannotShow(parsed, KEY_MAX_BUDGET_ALERT_EMAILS_KEY); + const { [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _moved, ...rest } = parsed; + const metadata = Object.keys(unmoved).length > 0 ? { ...rest, [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: unmoved } : rest; + const keptRows = (currentRows ?? []).filter((row) => !movedThresholds.includes(row.threshold)); + return { metadata: JSON.stringify(metadata, null, 2), rows: [...keptRows, ...typedRows], movedThresholds }; +}; + export const currentValuePlaceholder = ( premiumUser: boolean, current: unknown, diff --git a/litellm/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/litellm/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts index 9ad3efe84ce..800a5bdf079 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts +++ b/litellm/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts @@ -4,7 +4,13 @@ import { UseFormReturn, UseFormSetValue } from "react-hook-form"; import { KeyResponse } from "../key_team_helpers/key_list"; import { ToolPermissionsChangeOrigin } from "../mcp_server_management/MCPToolPermissions"; -import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; +import { extractLoggingSettings, formatMetadataForDisplay, stripFieldManagedKeysFromMetadata } from "../key_info_utils"; +import { + type BudgetAlertRow, + budgetAlertRowsFromMetadata, + budgetAlertRowsSchema, + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, +} from "../key_team_helpers/budgetAlertEmails"; import { mapInternalToDisplayNames } from "../callback_info_helpers"; import { estimateChecks, estimateFields } from "./estimatedOutputTokens"; import { canonicalBudgetDuration, keyOrganizationId } from "./keyEditFieldNormalizers"; @@ -26,6 +32,7 @@ export interface KeyEditFormValues { allowed_routes?: string; max_budget?: number | string | null; soft_budget?: number | string | null; + max_budget_alert_emails?: BudgetAlertRow[]; budget_duration?: string | null; tpm_limit?: number | string | null; tpm_limit_type?: string | null; @@ -76,6 +83,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues => max_budget: keyData.max_budget, soft_budget: (keyData.litellm_budget_table as { soft_budget?: number | null } | null | undefined)?.soft_budget ?? null, + max_budget_alert_emails: [...budgetAlertRowsFromMetadata(keyData.metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY)], budget_duration: canonicalBudgetDuration(keyData.budget_duration), tpm_limit: keyData.tpm_limit, tpm_limit_type: (keyData as { tpm_limit_type?: string | null }).tpm_limit_type ?? null, @@ -114,7 +122,7 @@ export const toKeyEditFormValues = (keyData: KeyResponse): KeyEditFormValues => team_id: keyData.team_id, project_id: keyData.project_id, logging_settings: extractLoggingSettings(keyData.metadata), - metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)), + metadata: formatMetadataForDisplay(stripFieldManagedKeysFromMetadata(keyData.metadata)), duration: (keyData as { duration?: string }).duration ?? "", token: keyData.token || keyData.token_id, disabled_callbacks: Array.isArray(readMetadata(keyData, "litellm_disabled_callbacks")) @@ -130,6 +138,11 @@ export const keyEditFormSchema = z.object({ allowed_routes: z.custom().optional(), max_budget: z.custom().optional(), soft_budget: z.custom().optional(), + max_budget_alert_emails: budgetAlertRowsSchema((issue) => + issue === "thresholdRange" + ? i18n.t("keyDetail.maxBudgetAlertEmails.thresholdRange") + : i18n.t("keyDetail.maxBudgetAlertEmails.duplicateThreshold"), + ).optional(), budget_duration: z.custom().optional(), tpm_limit: z.custom().optional(), tpm_limit_type: z.custom().optional(), @@ -187,6 +200,7 @@ export const toSubmittedValues = ( allowed_routes: values.allowed_routes, max_budget: values.max_budget, soft_budget: values.soft_budget, + max_budget_alert_emails: values.max_budget_alert_emails, budget_duration: values.budget_duration, tpm_limit: values.tpm_limit, tpm_limit_type: values.tpm_limit_type, diff --git a/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index 88d063358ca..ae21dfe7100 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -15,6 +15,7 @@ import { modelAvailableCall, vectorStoreListCall, } from "../networking"; +import { toast } from "@/lib/toast"; import { KeyEditView } from "./key_edit_view"; import { teamAdminMemberKeyPayload } from "./teamAdminMemberKeyPayload"; @@ -2577,6 +2578,7 @@ describe("KeyEditView", () => { models: [], max_budget: 0, soft_budget: null, + max_budget_alert_emails: [], budget_duration: "30d", tpm_limit: 10, tpm_limit_type: null, @@ -3308,4 +3310,185 @@ describe("KeyEditView", () => { }); }); }); + + describe("budget alert recipients", () => { + const KEY_WITH_ALERTS: KeyResponse = { + ...MOCK_KEY_DATA, + max_budget: 100, + metadata: { + ...MOCK_KEY_DATA.metadata, + cost_center: "ops", + max_budget_alert_emails: { "80": ["shared-mailbox@example.com", "finance-dl@example.com"], "50": [] }, + }, + }; + + const renderAlerts = ( + onSubmit: (values: Record) => Promise, + editingMemberKeyAsTeamAdmin = false, + keyData: KeyResponse = KEY_WITH_ALERTS, + ) => + renderWithProviders( + {}} + onSubmit={onSubmit} + accessToken={"test-token"} + userID={"test-user"} + userRole={"Admin"} + premiumUser={true} + editingMemberKeyAsTeamAdmin={editingMemberKeyAsTeamAdmin} + />, + ); + + const save = async (onSubmit: ReturnType) => { + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + return onSubmit.mock.calls[0][0]; + }; + + it("shows the stored thresholds as rows and keeps them out of the metadata JSON box", async () => { + renderAlerts(vi.fn()); + + const thresholds = await screen.findAllByPlaceholderText("% of budget"); + expect(thresholds.map((input) => (input as HTMLInputElement).value)).toEqual(["50", "80"]); + expect(screen.getAllByPlaceholderText("Additional recipients, e.g. finance@example.com")[1]).toHaveValue( + "shared-mailbox@example.com, finance-dl@example.com", + ); + expect(JSON.parse((screen.getByLabelText("Metadata") as HTMLTextAreaElement).value)).toEqual({ + cost_center: "ops", + }); + }); + + it("shows only the stored entries the rows cannot show in the metadata JSON box", async () => { + renderAlerts(vi.fn(), false, { + ...KEY_WITH_ALERTS, + metadata: { + cost_center: "ops", + max_budget_alert_emails: { "80": ["row@example.com"], "150": ["over@example.com"], "0": [] }, + }, + }); + + const thresholds = await screen.findAllByPlaceholderText("% of budget"); + expect(thresholds.map((input) => (input as HTMLInputElement).value)).toEqual(["80"]); + expect(JSON.parse((screen.getByLabelText("Metadata") as HTMLTextAreaElement).value)).toEqual({ + cost_center: "ops", + max_budget_alert_emails: { "150": ["over@example.com"], "0": [] }, + }); + }); + + it("submits an untouched key's rows unchanged", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAlerts(onSubmit); + await screen.findAllByPlaceholderText("% of budget"); + + expect((await save(onSubmit)).max_budget_alert_emails).toEqual([ + { threshold: 50, emails: "" }, + { threshold: 80, emails: "shared-mailbox@example.com, finance-dl@example.com" }, + ]); + expect(onSubmit.mock.calls[0][1]).not.toContain("max_budget_alert_emails"); + }); + + it("submits an added row and drops a removed one", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAlerts(onSubmit); + await screen.findAllByPlaceholderText("% of budget"); + + await userEvent.click(screen.getAllByRole("button", { name: "Remove budget alert threshold" })[0]); + await userEvent.click(screen.getByRole("button", { name: "Add Budget Alert Threshold" })); + const thresholds = screen.getAllByPlaceholderText("% of budget"); + fireEvent.change(thresholds[thresholds.length - 1], { target: { value: "100" } }); + const recipients = screen.getAllByPlaceholderText("Additional recipients, e.g. finance@example.com"); + fireEvent.change(recipients[recipients.length - 1], { target: { value: "ap-team@example.com" } }); + + expect((await save(onSubmit)).max_budget_alert_emails).toEqual([ + { threshold: 80, emails: "shared-mailbox@example.com, finance-dl@example.com" }, + { threshold: 100, emails: "ap-team@example.com" }, + ]); + expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails"); + }); + + it("reports the rows as edited when every row is removed, so the save clears them", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAlerts(onSubmit); + await screen.findAllByPlaceholderText("% of budget"); + + for (const button of screen.getAllByRole("button", { name: "Remove budget alert threshold" }).reverse()) { + await userEvent.click(button); + } + + expect((await save(onSubmit)).max_budget_alert_emails).toEqual([]); + expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails"); + }); + + it("moves a recipient map typed into the metadata JSON box into the rows on blur", async () => { + const info = vi.spyOn(toast, "info"); + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAlerts(onSubmit); + await screen.findAllByPlaceholderText("% of budget"); + + const metadataBox = screen.getByLabelText("Metadata"); + fireEvent.change(metadataBox, { + target: { + value: JSON.stringify({ + cost_center: "ops", + max_budget_alert_emails: { "80": ["typed@example.com"], "95": [] }, + }), + }, + }); + fireEvent.blur(metadataBox); + + expect(JSON.parse((metadataBox as HTMLTextAreaElement).value)).toEqual({ cost_center: "ops" }); + expect(info).toHaveBeenCalledWith( + "Moved alert thresholds 80%, 95% from metadata to the Budget Alert Thresholds rows", + ); + expect((await save(onSubmit)).max_budget_alert_emails).toEqual([ + { threshold: 50, emails: "" }, + { threshold: 80, emails: "typed@example.com" }, + { threshold: 95, emails: "" }, + ]); + expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails"); + }); + + it("moves a recipient map typed into the metadata JSON box into the rows even without a blur", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAlerts(onSubmit); + await screen.findAllByPlaceholderText("% of budget"); + + fireEvent.change(screen.getByLabelText("Metadata"), { + target: { + value: JSON.stringify({ cost_center: "ops", max_budget_alert_emails: { "80": ["typed@example.com"] } }), + }, + }); + fireEvent.submit(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.calls[0][0].max_budget_alert_emails).toEqual([ + { threshold: 50, emails: "" }, + { threshold: 80, emails: "typed@example.com" }, + ]); + expect(onSubmit.mock.calls[0][1]).toContain("max_budget_alert_emails"); + }); + + it("hides the rows from a team admin editing a member's key", async () => { + renderAlerts(vi.fn(), true); + + expect(await screen.findByLabelText("Metadata")).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("% of budget")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add Budget Alert Threshold" })).not.toBeInTheDocument(); + }); + + it("blocks the save on a duplicate threshold", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderAlerts(onSubmit); + await screen.findAllByPlaceholderText("% of budget"); + + await userEvent.click(screen.getByRole("button", { name: "Add Budget Alert Threshold" })); + const thresholds = screen.getAllByPlaceholderText("% of budget"); + fireEvent.change(thresholds[thresholds.length - 1], { target: { value: "80" } }); + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + expect(await screen.findAllByText("Duplicate threshold")).toHaveLength(2); + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index bafeedd5be7..1bb6676470c 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -38,6 +38,7 @@ import { KeyRateLimitFields, KeyTypeSelect, labelWithHint, + moveMetadataBudgetAlertsToKeyRows, moveMetadataTagsToTagsField, } from "./KeyEditViewControls"; import { @@ -49,6 +50,7 @@ import { clearMcpGrants, writeMcpToolPermissions, } from "./keyEditFormValues"; +import { BudgetAlertEmailsEditor } from "../key_team_helpers/BudgetAlertEmailsEditor"; import { BudgetFallbacksEditor } from "../key_team_helpers/BudgetFallbacksEditor"; import { END_USER_BUDGET_HINT, EndUserBudgetSelect } from "../key_team_helpers/EndUserBudgetSelect"; import { @@ -90,6 +92,7 @@ interface KeyEditViewProps { userID: string | null; userRole: string | null; premiumUser?: boolean; + editingMemberKeyAsTeamAdmin?: boolean; } const sessionKey = (keyData: KeyResponse) => keyData.token || keyData.token_id; @@ -105,6 +108,7 @@ function KeyEditSession({ userID, userRole, premiumUser = false, + editingMemberKeyAsTeamAdmin = false, }: KeyEditViewProps) { const { t } = useTranslation(); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); @@ -323,7 +327,7 @@ function KeyEditSession({ ...values, ...(detachProject && enableProjectsUI && canDetachProject ? { project_id: null } : {}), }), - [...Object.keys(dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])], + [...Object.keys(form.formState.dirtyFields), ...(budgetLimitsUnchanged ? [] : ["budget_limits"])], ); } finally { setIsKeySaving(false); @@ -373,16 +377,17 @@ function KeyEditSession({ ); }; + const submitForm = (event: React.FormEvent) => { + moveMetadataTagsToTagsField(form, t); + if (!editingMemberKeyAsTeamAdmin) moveMetadataBudgetAlertsToKeyRows(form, t); + return form.handleSubmit((values) => handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })))( + event, + ); + }; + return ( -
{ - moveMetadataTagsToTagsField(form, t); - return form.handleSubmit((values) => - handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })), - )(event); - }} - > + {(field) => } @@ -467,6 +472,23 @@ function KeyEditSession({ placeholder={t("keyDetail.keyEditView.fields.softBudgetPlaceholder")} /> + {!editingMemberKeyAsTeamAdmin && ( + + )} + - +
diff --git a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 8a30c6ff7a7..78607a70058 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -17,6 +17,7 @@ import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useMode const editViewMocks = vi.hoisted(() => ({ onSubmit: undefined as ((v: Record) => Promise) | undefined, + editingMemberKeyAsTeamAdmin: undefined as boolean | undefined, })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => "/ui/alerts" })); @@ -26,8 +27,15 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ })); vi.mock("./key_edit_view", () => ({ - KeyEditView: ({ onSubmit }: { onSubmit: (v: Record) => Promise }) => { + KeyEditView: ({ + onSubmit, + editingMemberKeyAsTeamAdmin, + }: { + onSubmit: (v: Record) => Promise; + editingMemberKeyAsTeamAdmin?: boolean; + }) => { editViewMocks.onSubmit = onSubmit; + editViewMocks.editingMemberKeyAsTeamAdmin = editingMemberKeyAsTeamAdmin; return
; }, })); @@ -892,7 +900,9 @@ describe("KeyInfoView", () => { await renderAndOpenSettingsTab({ ...MOCK_KEY_DATA, user_id: "someone-else-id" }); - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /edit settings/i })); + expect(await screen.findByTestId("key-edit-view-stub")).toBeInTheDocument(); + expect(editViewMocks.editingMemberKeyAsTeamAdmin).toBe(false); }); it("should show the Edit Settings button when the user is the key owner", async () => { @@ -961,7 +971,9 @@ describe("KeyInfoView", () => { await renderAndOpenSettingsTab({ ...MOCK_KEY_DATA, team_id: teamId, user_id: "other-user-id" }); - expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /edit settings/i })); + expect(await screen.findByTestId("key-edit-view-stub")).toBeInTheDocument(); + expect(editViewMocks.editingMemberKeyAsTeamAdmin).toBe(true); }); }); diff --git a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 317a3c309ab..8e5f9262101 100644 --- a/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/litellm/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -39,6 +39,12 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal"; import RouterSettingsSummary from "../common_components/RouterSettingsSummary"; import { hasRouterSettings } from "../common_components/routerSettingsPayload"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; +import { + budgetAlertEmailsFromRows, + budgetAlertEmailsMetadata, + budgetAlertEmailsRowsCanShow, + KEY_MAX_BUDGET_ALERT_EMAILS_KEY, +} from "../key_team_helpers/budgetAlertEmails"; import { KeyResponse } from "../key_team_helpers/key_list"; import LoggingSettingsView from "../logging_settings_view"; import { toast } from "@/lib/toast"; @@ -219,6 +225,15 @@ export default function KeyInfoView({ ); } + const memberKeyEditContext = { + userRole: userRole || "", + userId: userID || "", + keyUserId: currentKeyData.user_id, + keyTeamId: currentKeyData.team_id, + teamMembers: teamsData?.find((team) => team.team_id === currentKeyData.team_id)?.members_with_roles, + }; + const editingMemberKeyAsTeamAdmin = isTeamAdminEditingMemberKey(memberKeyEditContext); + const handleKeyUpdate = async (formValues: Record, dirtyFields: readonly string[] = []) => { try { if (!accessToken) return; @@ -334,6 +349,12 @@ export default function KeyInfoView({ formValues.tpd_limit = mapEmptyStringToNull(formValues.tpd_limit); formValues.max_parallel_requests = mapEmptyStringToNull(formValues.max_parallel_requests); + const budgetAlertRowEntries = dirtyFields.includes(KEY_MAX_BUDGET_ALERT_EMAILS_KEY) + ? budgetAlertEmailsFromRows(formValues.max_budget_alert_emails ?? []) + : budgetAlertEmailsRowsCanShow(currentKeyData.metadata, KEY_MAX_BUDGET_ALERT_EMAILS_KEY); + const budgetAlertEmailsWithBoxEntries = (boxMetadata: unknown) => + budgetAlertEmailsMetadata(budgetAlertRowEntries, KEY_MAX_BUDGET_ALERT_EMAILS_KEY, boxMetadata); + // Convert metadata back to an object if it exists and is a string if (formValues.metadata && typeof formValues.metadata === "string") { try { @@ -342,9 +363,12 @@ export default function KeyInfoView({ if ("tags" in parsedMetadata) { delete parsedMetadata["tags"]; } + const budgetAlertEmails = budgetAlertEmailsWithBoxEntries(parsedMetadata); + delete parsedMetadata[KEY_MAX_BUDGET_ALERT_EMAILS_KEY]; formValues.metadata = { ...parsedMetadata, ...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}), + ...budgetAlertEmails, ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), ...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0 ? { logging: formValues.logging_settings } @@ -362,10 +386,11 @@ export default function KeyInfoView({ } } else { const baseMetadata = formValues.metadata || {}; - const { tags: _omitTags, ...rest } = baseMetadata; + const { tags: _omitTags, [KEY_MAX_BUDGET_ALERT_EMAILS_KEY]: _boxAlertEmails, ...rest } = baseMetadata; formValues.metadata = { ...rest, ...(Array.isArray(formValues.tags) && formValues.tags.length > 0 ? { tags: formValues.tags } : {}), + ...budgetAlertEmailsWithBoxEntries(baseMetadata), ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), ...(Array.isArray(formValues.logging_settings) && formValues.logging_settings.length > 0 ? { logging: formValues.logging_settings } @@ -383,6 +408,7 @@ export default function KeyInfoView({ delete formValues.tags; } delete formValues.logging_settings; + delete formValues.max_budget_alert_emails; // Normalize any legacy word-form budget_duration to the canonical API format if (formValues.budget_duration) { @@ -395,14 +421,6 @@ export default function KeyInfoView({ formValues.budget_duration = wordToCanonical[formValues.budget_duration] ?? formValues.budget_duration; } - const memberKeyEditContext = { - userRole: userRole || "", - userId: userID || "", - keyUserId: currentKeyData.user_id, - keyTeamId: currentKeyData.team_id, - teamMembers: teamsData?.find((team) => team.team_id === currentKeyData.team_id)?.members_with_roles, - }; - const editingMemberKeyAsTeamAdmin = isTeamAdminEditingMemberKey(memberKeyEditContext); if (editingMemberKeyAsTeamAdmin) { const trimmed = teamAdminMemberKeyPayload(formValues, dirtyFields); if (trimmed.kind === "blocked") { @@ -1010,6 +1028,7 @@ export default function KeyInfoView({ userID={userID} userRole={userRole} premiumUser={premiumUser} + editingMemberKeyAsTeamAdmin={editingMemberKeyAsTeamAdmin} /> ) : (
diff --git a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json index 6e09e3eb785..bdade69fef6 100644 --- a/litellm/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/litellm/ui/litellm-dashboard/src/i18n/locales/en.json @@ -2391,6 +2391,17 @@ "description": "Tags are managed by the Tags field above. A tags array typed here is moved to that field." } }, + "maxBudgetAlertEmails": { + "budgetAlertThresholds": "Budget Alert Thresholds", + "budgetAlertThresholdsHint": "Thresholds are a percentage of the key's max budget and replace this key's default 80% alert. Global alert thresholds configured on the proxy still apply. Thresholds outside 1 to 100 can be edited in the Metadata box. The key owner's email is always included. Add comma-separated addresses, such as a shared mailbox or distribution list, to notify others as well. Requires email alerting to be configured on the proxy.", + "thresholdPlaceholder": "% of budget", + "recipientsPlaceholder": "Additional recipients, e.g. finance@example.com", + "removeThreshold": "Remove budget alert threshold", + "addThreshold": "Add Budget Alert Threshold", + "thresholdRange": "Enter a whole number from 1 to 100", + "duplicateThreshold": "Duplicate threshold", + "movedThresholds": "Moved alert thresholds {{thresholds}} from metadata to the Budget Alert Thresholds rows" + }, "estimatedOutputTokens": { "invalidPerModel": "Enter a JSON object of positive integers, e.g. {\"gpt-4\": 4096}", "invalidPositive": "Enter a positive integer", diff --git a/tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts b/tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts new file mode 100644 index 00000000000..af9c0942331 --- /dev/null +++ b/tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts @@ -0,0 +1,164 @@ +import { test, expect, type Page as PlaywrightPage } from "@playwright/test"; +import { + ADMIN_STORAGE_PATH, + E2E_TEAM_CRUD_ALIAS, + E2E_TEAM_CRUD_ID, +} from "../../constants"; +import { Page } from "../../fixtures/pages"; +import { + dismissFeedbackPopup, + navigateToPage, + openKeyDetail, +} from "../../helpers/navigation"; +import { readBack } from "../../helpers/roundTrip"; +import { + createVirtualKey, + deleteVirtualKey, + uniqueSuffix, +} from "../../helpers/traffic"; + +const SHARED_MAILBOX = "shared-mailbox@example.com"; +const DISTRIBUTION_LIST = "finance-dl@example.com"; +const RECIPIENTS_PLACEHOLDER = + "Additional recipients, e.g. finance@example.com"; + +async function keyMetadata( + page: PlaywrightPage, + token: string, +): Promise> { + const body = await readBack<{ info: { metadata: Record } }>( + page, + `/key/info?key=${encodeURIComponent(token)}`, + ); + return body.info.metadata; +} + +async function tokenForAlias( + page: PlaywrightPage, + alias: string, +): Promise { + const body = await readBack<{ keys: { key_alias: string; token: string }[] }>( + page, + `/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`, + ); + return body.keys.find((row) => row.key_alias === alias)?.token; +} + +async function addAlertRow( + page: PlaywrightPage, + threshold: string, + emails: string, +): Promise { + await page + .getByRole("button", { name: "Add Budget Alert Threshold" }) + .click(); + await page.getByPlaceholder("% of budget").last().fill(threshold); + await page.getByPlaceholder(RECIPIENTS_PLACEHOLDER).last().fill(emails); +} + +test.describe("Proxy Admin - Key budget alert recipients", () => { + test.use({ storageState: ADMIN_STORAGE_PATH }); + + test("Create Key stores the alert recipients next to the metadata JSON", async ({ + page, + }) => { + const alias = `e2e-alert-create-${uniqueSuffix()}`; + await navigateToPage(page, Page.ApiKeys); + await dismissFeedbackPopup(page); + await page.getByRole("button", { name: /Create New Key/i }).click(); + await page.getByLabel(/Key Name/).fill(alias); + await page.getByTestId("team-dropdown").getByRole("combobox").click(); + await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); + await page + .getByRole("option", { name: E2E_TEAM_CRUD_ALIAS }) + .first() + .click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page + .getByRole("option", { name: "All Team Models", exact: true }) + .click(); + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: /Optional Settings/i }).click(); + await page.getByLabel(/Max Budget \(USD\)/).fill("10"); + await addAlertRow(page, "80", `${SHARED_MAILBOX}, ${DISTRIBUTION_LIST}`); + await page.getByLabel("Metadata").fill('{"cost_center": "ops"}'); + await page.getByRole("button", { name: "Create Key", exact: true }).click(); + await expect(page.getByText("Save your Key")).toBeVisible({ + timeout: 10_000, + }); + + const token = await tokenForAlias(page, alias); + expect(token, `key ${alias} readable from /key/list`).toBeTruthy(); + try { + expect(await keyMetadata(page, token!)).toMatchObject({ + cost_center: "ops", + max_budget_alert_emails: { "80": [SHARED_MAILBOX, DISTRIBUTION_LIST] }, + }); + } finally { + await deleteVirtualKey(page.request, token!); + } + }); + + test("Edit Key round-trips the rows, keeps the rest of the metadata, and clears them when every row is removed", async ({ + page, + }) => { + const alias = `e2e-alert-edit-${uniqueSuffix()}`; + const created = await createVirtualKey(page.request, { + key_alias: alias, + team_id: E2E_TEAM_CRUD_ID, + max_budget: 10, + metadata: { + cost_center: "ops", + max_budget_alert_emails: { "80": [SHARED_MAILBOX] }, + }, + }); + try { + await navigateToPage(page, Page.ApiKeys); + await dismissFeedbackPopup(page); + await openKeyDetail(page, alias); + await page.getByRole("tab", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Edit Settings" }).click(); + + await expect(page.getByPlaceholder("% of budget")).toHaveValue("80"); + await expect(page.getByPlaceholder(RECIPIENTS_PLACEHOLDER)).toHaveValue( + SHARED_MAILBOX, + ); + expect( + JSON.parse(await page.getByLabel("Metadata").inputValue()), + ).toEqual({ cost_center: "ops" }); + + await page + .getByRole("button", { name: "Remove budget alert threshold" }) + .click(); + await addAlertRow(page, "50", DISTRIBUTION_LIST); + await page.getByRole("button", { name: "Save Changes" }).click(); + await expect + .poll(() => keyMetadata(page, created.token), { timeout: 20_000 }) + .toMatchObject({ + cost_center: "ops", + max_budget_alert_emails: { "50": [DISTRIBUTION_LIST] }, + }); + + await page.getByRole("button", { name: "Edit Settings" }).click(); + await expect(page.getByPlaceholder("% of budget")).toHaveValue("50"); + await page + .getByRole("button", { name: "Remove budget alert threshold" }) + .click(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await expect + .poll( + async () => + "max_budget_alert_emails" in + (await keyMetadata(page, created.token)), + { timeout: 20_000 }, + ) + .toBe(false); + expect(await keyMetadata(page, created.token)).toMatchObject({ + cost_center: "ops", + }); + } finally { + await deleteVirtualKey(page.request, created.token); + } + }); +}); diff --git a/tests/integration/spend/test_key_budget_alerts.py b/tests/integration/spend/test_key_budget_alerts.py new file mode 100644 index 00000000000..a904d2ba03a --- /dev/null +++ b/tests/integration/spend/test_key_budget_alerts.py @@ -0,0 +1,74 @@ +import uuid +from pathlib import Path +from typing import Final + +import pytest +import yaml +from integration._support.client import Gateway, eventually +from integration._support.database import read_rows +from integration._support.mail import smtp_sink +from integration._support.process import owned_proxy + +pytestmark: Final = pytest.mark.shares_worker_state + +KEY_BUDGET: Final = 0.10 +CALL_COST: Final = 20 * 0.001 + 20 * 0.002 + + +def _key_spend(key: str) -> float: + rows: Final = read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE key_alias = %s', (key,)) + return float(str(rows[0]["spend"])) if rows else 0.0 + + +def test_key_threshold_emails_owner_and_shared_mailbox_from_admin_ui_metadata(gateway: Gateway, tmp_path: Path) -> None: + owner_email: Final = f"owner-{uuid.uuid4().hex}@integration.test" + shared_mailbox: Final = f"shared-mailbox-{uuid.uuid4().hex}@integration.test" + alias: Final = f"alert-key-{uuid.uuid4().hex}" + configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + configuration["general_settings"]["alerting"] = ["email"] + configuration["general_settings"]["user_api_key_cache_ttl"] = 1 + path: Final = tmp_path / "email-alerting.yaml" + path.write_text(yaml.safe_dump(configuration)) + with smtp_sink() as mailbox: + overrides: Final = { + "SMTP_HOST": mailbox.host, + "SMTP_PORT": str(mailbox.port), + "SMTP_TLS": "False", + "SMTP_SENDER_EMAIL": "alerts@integration.test", + } + with owned_proxy(gateway, tmp_path, overrides, config=path) as candidate, candidate.scenario() as scenario: + model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) + user_id: Final = scenario.user(user_email=owner_email) + key: Final = scenario.key( + key_alias=alias, + user_id=user_id, + models=[model], + max_budget=KEY_BUDGET, + metadata={"cost_center": "ops", "max_budget_alert_emails": {"50": [shared_mailbox]}}, + ) + + first: Final = candidate.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": "first call"}]}, + key=key, + ) + assert first.status_code == 200, first.text + assert float(first.headers["x-litellm-response-cost"]) == pytest.approx(CALL_COST) + eventually(lambda: _key_spend(alias), lambda spend: spend == pytest.approx(CALL_COST), seconds=70) + assert mailbox.deliveries() == (), "no threshold is reached before the first call is recorded" + + second: Final = candidate.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": "second call"}]}, + key=key, + ) + assert second.status_code == 200, second.text + halfway: Final = eventually(mailbox.deliveries, lambda found: len(found) >= 2, seconds=30) + assert {recipient for delivery in halfway for recipient in delivery.recipients} == { + owner_email, + shared_mailbox, + }, halfway + assert all("50%" in delivery.subject for delivery in halfway), halfway + assert len(halfway) == 2, halfway