mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(ui): set per-threshold budget alert recipients on virtual keys (#51061)
* feat(ui): set per-threshold budget alert recipients on virtual keys Create Key and Edit Key get the threshold and recipients rows the team member budget alerts already use, writing metadata.max_budget_alert_emails. The helpers and the row editor move to key_team_helpers so the team form and both key forms share them. * test(ui): cover the empty metadata box path and drop a redundant payload case * fix(ui): keep comma-separated string alert recipients stored through the API The backend accepts a string of recipients per threshold, so Edit Key and the team form now load them as rows instead of dropping them and deleting the alert on the next save * fix(ui): keep the stored key alert map on saves that do not edit its rows Edit Key rebuilt max_budget_alert_emails from the rows on every save, so API-written entries the rows cannot show (a null recipient list, a list with a null, thresholds outside 1 to 100) were deleted by unrelated edits. The map is now rebuilt only when the rows were edited and otherwise sent back as stored * fix(ui): load null and mixed alert recipient lists as rows and drop a typed map on Create Key A null recipient list loads as an owner-only row and a mixed list keeps its strings, matching what the backend sends. Create Key now ignores a max_budget_alert_emails typed into the Metadata box, as Edit Key does, and the hint says thresholds replace the default 80% alert * fix(ui): keep Create Key alert rows when the Metadata box holds null * fix(ui): move typed alert maps into rows, keep hidden alert entries, hide alert rows from team admins A max_budget_alert_emails map typed into the Metadata box on Edit Key or Create Key now moves into the threshold rows on blur with a toast, the same way a tags array moves to the Tags field. Entries the rows cannot show stay in the JSON box. When the rows are edited, the saved map keeps the stored entries the rows cannot show (thresholds outside 1 to 100, non-numeric keys, unreadable values). Create Key keeps such typed entries too. Team admins editing a member's key no longer see the alert rows, since that save only accepts budget fields * fix(ui): let the Metadata box own key alert entries the threshold rows cannot show Edit Key now shows max_budget_alert_emails entries outside whole numbers 1 to 100 in the Metadata JSON box and saves them from the box as the admin left it, so they can be seen, edited and removed. Rows still own every entry they can show, and an untouched save sends the stored map unchanged * fix(ui): leave zero-padded alert thresholds to the Metadata box instead of duplicating a row * fix(ui): keep loading zero-padded team member alert thresholds as rows The canonical-key rule applies only to key alert maps, where the Metadata box holds entries the rows cannot show. The team form has no such fallback, so it keeps loading "080" as row 80 as it did before * fix(ui): let the Metadata box own a non-object max_budget_alert_emails value A string, list, number or null stored under max_budget_alert_emails stays in the Edit Key Metadata box, is saved back as the box holds it, and is stored as typed on Create Key. Threshold rows only own an object map, so rows the admin adds replace a non-object value * fix(ui): move typed alert thresholds into the rows when Edit Key saves without a blur Submit now moves a max_budget_alert_emails map typed into the Metadata box into the rows, as it already does for tags, and reads the edited fields at submit time so the moved rows are saved * refactor(ui): name the Edit Key submit handler
This commit is contained in:
parent
91808ed2cf
commit
f0021859a7
24 changed files with 1537 additions and 287 deletions
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const stripFieldManagedKeysFromMetadata = (
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null | undefined => {
|
||||
const withoutTags: Record<string, unknown> | 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) };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<TFieldValues extends FieldValues> = string extends keyof TFieldValues ? string : ArrayPath<TFieldValues>;
|
||||
|
||||
interface BudgetAlertEmailsEditorProps<TFieldValues extends FieldValues> {
|
||||
readonly control: Control<TFieldValues>;
|
||||
readonly name: RowsPath<TFieldValues>;
|
||||
readonly label: React.ReactNode;
|
||||
readonly text: BudgetAlertEmailsEditorText;
|
||||
}
|
||||
|
||||
const EMPTY_ROW: BudgetAlertRow = { threshold: null, emails: "" };
|
||||
|
||||
export const BudgetAlertEmailsEditor = <TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
text,
|
||||
}: BudgetAlertEmailsEditorProps<TFieldValues>) => {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: name as ArrayPath<TFieldValues> });
|
||||
const rowField = (index: number, column: keyof BudgetAlertRow) =>
|
||||
`${name}.${index}.${column}` as FieldPath<TFieldValues>;
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
{fields.map((row, index) => (
|
||||
<div key={row.id} className="mb-2 flex items-start gap-2">
|
||||
<FormField control={control} name={rowField(index, "threshold")} className="w-32">
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<NumericalInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(event.target.value === "" ? null : Number(event.target.value))
|
||||
}
|
||||
placeholder={text.thresholdPlaceholder}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={control} name={rowField(index, "emails")} className="flex-1">
|
||||
{({ ref, value, ...field }) => (
|
||||
<Input {...field} ref={ref} value={value ?? ""} placeholder={text.recipientsPlaceholder} />
|
||||
)}
|
||||
</FormField>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={text.removeThreshold}
|
||||
className="mt-1 text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<CircleMinus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-dashed"
|
||||
onClick={() => append(EMPTY_ROW as Parameters<typeof append>[0])}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{text.addThreshold}
|
||||
</Button>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, readonly string[]>>;
|
||||
|
||||
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<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const storedBudgetAlertValue = (metadata: unknown, metadataKey: BudgetAlertEmailsMetadataKey): unknown =>
|
||||
typeof metadata === "object" && metadata !== null ? (metadata as Record<string, unknown>)[metadataKey] : undefined;
|
||||
|
||||
const storedBudgetAlertConfig = (
|
||||
metadata: unknown,
|
||||
metadataKey: BudgetAlertEmailsMetadataKey,
|
||||
): Readonly<Record<string, unknown>> => {
|
||||
const config = storedBudgetAlertValue(metadata, metadataKey);
|
||||
return isBudgetAlertMap(config) ? config : {};
|
||||
};
|
||||
|
||||
const ROW_THRESHOLD_KEY_PATTERN: Readonly<Record<BudgetAlertEmailsMetadataKey, RegExp>> = {
|
||||
[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<Record<string, unknown>> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(storedBudgetAlertConfig(metadata, metadataKey)).filter(
|
||||
(entry) => (budgetAlertRowFromEntry(entry, metadataKey) !== null) === rowsCanShow,
|
||||
),
|
||||
);
|
||||
|
||||
export const budgetAlertEmailsRowsCanShow = (
|
||||
metadata: unknown,
|
||||
metadataKey: BudgetAlertEmailsMetadataKey,
|
||||
): Readonly<Record<string, unknown>> => storedBudgetAlertEntries(metadata, metadataKey, true);
|
||||
|
||||
export const budgetAlertEmailsRowsCannotShow = (
|
||||
metadata: unknown,
|
||||
metadataKey: BudgetAlertEmailsMetadataKey,
|
||||
): Readonly<Record<string, unknown>> => 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<Record<string, unknown>>,
|
||||
metadataKey: BudgetAlertEmailsMetadataKey,
|
||||
metadataWithHiddenEntries: unknown,
|
||||
): Readonly<Partial<Record<BudgetAlertEmailsMetadataKey, unknown>>> => {
|
||||
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`,
|
||||
);
|
||||
|
|
@ -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<string, unknown> => {
|
||||
expect(result.kind).toBe("ok");
|
||||
if (result.kind !== "ok") throw new Error("unreachable");
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, 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<string>([
|
||||
"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"] : []),
|
||||
|
|
|
|||
|
|
@ -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[] });
|
||||
|
|
|
|||
|
|
@ -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<MountedFormValues>;
|
||||
readonly label: React.ReactNode;
|
||||
readonly text: BudgetAlertEmailsEditorText;
|
||||
}) => {
|
||||
useMountedName(KEY_MAX_BUDGET_ALERT_EMAILS_KEY);
|
||||
return <BudgetAlertEmailsEditor {...props} name={KEY_MAX_BUDGET_ALERT_EMAILS_KEY} />;
|
||||
};
|
||||
|
||||
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
|
||||
|
||||
const requiredRule = (required: boolean, message: string) => ({
|
||||
|
|
@ -532,7 +549,21 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) =>
|
||||
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<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
<div className="mt-4">
|
||||
<MountedBudgetAlertEmailsEditor
|
||||
control={form.control}
|
||||
label={
|
||||
<span>
|
||||
{t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholds")}{" "}
|
||||
<SimpleTooltip content={t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholdsHint")}>
|
||||
<Info className="ml-1 inline size-3.5 align-text-bottom" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
}
|
||||
text={{
|
||||
thresholdPlaceholder: t("keyDetail.maxBudgetAlertEmails.thresholdPlaceholder"),
|
||||
recipientsPlaceholder: t("keyDetail.maxBudgetAlertEmails.recipientsPlaceholder"),
|
||||
removeThreshold: t("keyDetail.maxBudgetAlertEmails.removeThreshold"),
|
||||
addThreshold: t("keyDetail.maxBudgetAlertEmails.addThreshold"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<MountedFormField
|
||||
className="mt-4"
|
||||
label={
|
||||
|
|
@ -1599,6 +1649,18 @@ const CreateKey: React.FC<CreateKeyProps> = ({ 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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<TeamInfoProps> = ({
|
|||
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<TeamInfoProps> = ({
|
|||
|
||||
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<TeamInfoProps> = ({
|
|||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<Field>
|
||||
<FieldLabel>
|
||||
{labelWithHint(
|
||||
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholds"),
|
||||
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholdsHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
{memberBudgetAlertRows.map((row, index) => (
|
||||
<div key={row.id} className="mb-2 flex items-start gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`team_member_max_budget_alert_emails.${index}.threshold`}
|
||||
className="w-32"
|
||||
>
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<NumericalInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(event.target.value === "" ? null : Number(event.target.value))
|
||||
}
|
||||
placeholder={t("teams.teamInfo.editForm.teamMemberSettings.thresholdPlaceholder")}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`team_member_max_budget_alert_emails.${index}.emails`}
|
||||
className="flex-1"
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
<UIInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
placeholder={t("teams.teamInfo.editForm.teamMemberSettings.recipientsPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("teams.teamInfo.editForm.teamMemberSettings.removeThreshold")}
|
||||
className="mt-1 text-destructive"
|
||||
onClick={() => removeMemberBudgetAlertRow(index)}
|
||||
>
|
||||
<CircleMinus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-dashed"
|
||||
onClick={() => appendMemberBudgetAlertRow({ threshold: null, emails: "" })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("teams.teamInfo.editForm.teamMemberSettings.addThreshold")}
|
||||
</Button>
|
||||
</Field>
|
||||
<BudgetAlertEmailsEditor
|
||||
control={form.control}
|
||||
name="team_member_max_budget_alert_emails"
|
||||
label={labelWithHint(
|
||||
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholds"),
|
||||
t("teams.teamInfo.editForm.teamMemberSettings.budgetAlertThresholdsHint"),
|
||||
)}
|
||||
text={{
|
||||
thresholdPlaceholder: t("teams.teamInfo.editForm.teamMemberSettings.thresholdPlaceholder"),
|
||||
recipientsPlaceholder: t(
|
||||
"teams.teamInfo.editForm.teamMemberSettings.recipientsPlaceholder",
|
||||
),
|
||||
removeThreshold: t("teams.teamInfo.editForm.teamMemberSettings.removeThreshold"),
|
||||
addThreshold: t("teams.teamInfo.editForm.teamMemberSettings.addThreshold"),
|
||||
}}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, readonly string[]>>;
|
||||
|
||||
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<string, unknown>)[TEAM_MEMBER_MAX_BUDGET_ALERT_EMAILS_KEY];
|
||||
if (typeof config !== "object" || config === null || Array.isArray(config)) return [];
|
||||
return Object.entries(config as Record<string, unknown>)
|
||||
.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`,
|
||||
);
|
||||
|
|
@ -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 (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -204,7 +239,7 @@ export const KeyMetadataField = ({ form }: { form: KeyEditForm }) => {
|
|||
rows={10}
|
||||
onBlur={() => {
|
||||
field.onBlur();
|
||||
moveMetadataTagsToTagsField(form, t);
|
||||
moveTypedFieldsOutOfMetadata();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = baseKeyData) => {
|
||||
// Configure the mock for this test
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "access_abc",
|
||||
|
|
@ -245,19 +246,14 @@ const renderView = (premiumUser: boolean) => {
|
|||
});
|
||||
|
||||
return render(
|
||||
<KeyInfoView
|
||||
keyId="tok_123"
|
||||
onClose={() => {}}
|
||||
keyData={baseKeyData as any}
|
||||
onKeyDataUpdate={() => {}}
|
||||
teams={[]}
|
||||
/>,
|
||||
<KeyInfoView keyId="tok_123" onClose={() => {}} 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<string, unknown>,
|
||||
dirtyFields: readonly string[] = ["max_budget_alert_emails"],
|
||||
keyData: Record<string, unknown> = 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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>().optional(),
|
||||
max_budget: z.custom<number | string | null | undefined>().optional(),
|
||||
soft_budget: z.custom<number | string | null | undefined>().optional(),
|
||||
max_budget_alert_emails: budgetAlertRowsSchema((issue) =>
|
||||
issue === "thresholdRange"
|
||||
? i18n.t("keyDetail.maxBudgetAlertEmails.thresholdRange")
|
||||
: i18n.t("keyDetail.maxBudgetAlertEmails.duplicateThreshold"),
|
||||
).optional(),
|
||||
budget_duration: z.custom<string | null | undefined>().optional(),
|
||||
tpm_limit: z.custom<number | string | null | undefined>().optional(),
|
||||
tpm_limit_type: z.custom<string | null | undefined>().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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => Promise<void>,
|
||||
editingMemberKeyAsTeamAdmin = false,
|
||||
keyData: KeyResponse = KEY_WITH_ALERTS,
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyData}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"Admin"}
|
||||
premiumUser={true}
|
||||
editingMemberKeyAsTeamAdmin={editingMemberKeyAsTeamAdmin}
|
||||
/>,
|
||||
);
|
||||
|
||||
const save = async (onSubmit: ReturnType<typeof vi.fn>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement>) => {
|
||||
moveMetadataTagsToTagsField(form, t);
|
||||
if (!editingMemberKeyAsTeamAdmin) moveMetadataBudgetAlertsToKeyRows(form, t);
|
||||
return form.handleSubmit((values) => handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })))(
|
||||
event,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
moveMetadataTagsToTagsField(form, t);
|
||||
return form.handleSubmit((values) =>
|
||||
handleSubmit(toSubmittedValues(values, { canViewPolicies, canViewPrompts })),
|
||||
)(event);
|
||||
}}
|
||||
>
|
||||
<form onSubmit={submitForm}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="key_alias" label={t("keyDetail.keyEditView.fields.keyAlias")}>
|
||||
{(field) => <Input {...field} value={(field.value as string | undefined) ?? ""} />}
|
||||
|
|
@ -467,6 +472,23 @@ function KeyEditSession({
|
|||
placeholder={t("keyDetail.keyEditView.fields.softBudgetPlaceholder")}
|
||||
/>
|
||||
|
||||
{!editingMemberKeyAsTeamAdmin && (
|
||||
<BudgetAlertEmailsEditor
|
||||
control={form.control}
|
||||
name="max_budget_alert_emails"
|
||||
label={labelWithHint(
|
||||
t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholds"),
|
||||
t("keyDetail.maxBudgetAlertEmails.budgetAlertThresholdsHint"),
|
||||
)}
|
||||
text={{
|
||||
thresholdPlaceholder: t("keyDetail.maxBudgetAlertEmails.thresholdPlaceholder"),
|
||||
recipientsPlaceholder: t("keyDetail.maxBudgetAlertEmails.recipientsPlaceholder"),
|
||||
removeThreshold: t("keyDetail.maxBudgetAlertEmails.removeThreshold"),
|
||||
addThreshold: t("keyDetail.maxBudgetAlertEmails.addThreshold"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budget_duration"
|
||||
|
|
@ -896,7 +918,7 @@ function KeyEditSession({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<KeyMetadataField form={form} />
|
||||
<KeyMetadataField form={form} budgetAlertRowsShown={!editingMemberKeyAsTeamAdmin} />
|
||||
|
||||
<div className="mb-4">
|
||||
<FormField control={form.control} name="duration">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useModelAccessGroupNames } from "@/app/(dashboard)/hooks/models/useMode
|
|||
|
||||
const editViewMocks = vi.hoisted(() => ({
|
||||
onSubmit: undefined as ((v: Record<string, any>) => Promise<void>) | 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<string, any>) => Promise<void> }) => {
|
||||
KeyEditView: ({
|
||||
onSubmit,
|
||||
editingMemberKeyAsTeamAdmin,
|
||||
}: {
|
||||
onSubmit: (v: Record<string, any>) => Promise<void>;
|
||||
editingMemberKeyAsTeamAdmin?: boolean;
|
||||
}) => {
|
||||
editViewMocks.onSubmit = onSubmit;
|
||||
editViewMocks.editingMemberKeyAsTeamAdmin = editingMemberKeyAsTeamAdmin;
|
||||
return <div data-testid="key-edit-view-stub" />;
|
||||
},
|
||||
}));
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>, 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}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
164
tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts
Normal file
164
tests/e2e/ui/tests/proxy-admin/keyBudgetAlertRecipients.spec.ts
Normal file
|
|
@ -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<Record<string, unknown>> {
|
||||
const body = await readBack<{ info: { metadata: Record<string, unknown> } }>(
|
||||
page,
|
||||
`/key/info?key=${encodeURIComponent(token)}`,
|
||||
);
|
||||
return body.info.metadata;
|
||||
}
|
||||
|
||||
async function tokenForAlias(
|
||||
page: PlaywrightPage,
|
||||
alias: string,
|
||||
): Promise<string | undefined> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
tests/integration/spend/test_key_budget_alerts.py
Normal file
74
tests/integration/spend/test_key_budget_alerts.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue