This commit is contained in:
devin-ai-integration[bot] 2026-08-27 16:35:53 -04:00 committed by GitHub
commit cdfdf5ddab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 133 additions and 4 deletions

View file

@ -320,7 +320,11 @@ export const getKeyTableColumns = ({
<SpendBudgetCell
spend={row.original.spend}
maxBudget={row.original.max_budget}
inheritedGates={row.original.max_budget == null ? inheritedBudgetGates(team, organization) : []}
inheritedGates={
row.original.max_budget == null
? inheritedBudgetGates(team, organization, row.original.team_id ? null : row.original.user)
: []
}
/>
);
},

View file

@ -111,6 +111,8 @@ export interface KeyResponse {
user_id: string;
user_email: string;
user_alias: string | null;
max_budget?: number | null;
budget_duration?: string | null;
};
created_by_user?: {
user_id: string;

View file

@ -10,6 +10,13 @@ const organization = {
organization_alias: "Acme",
litellm_budget_table: { max_budget: 5000, budget_duration: null },
};
const user = {
user_id: "user-1",
user_email: "owner@example.com",
user_alias: "Key Owner",
max_budget: 1500,
budget_duration: "1mo",
};
describe("inheritedBudgetGates", () => {
it("returns team then org gates when both have budgets", () => {
@ -42,6 +49,30 @@ describe("inheritedBudgetGates", () => {
),
).toEqual(["team-1", "org-1"]);
});
it("returns the owner's user budget as a gate", () => {
expect(inheritedBudgetGates(null, null, user)).toEqual([
{ scope: "User", alias: "Key Owner", maxBudget: 1500, budgetDuration: "1mo" },
]);
});
it("skips the user gate when the owner has no budget", () => {
expect(inheritedBudgetGates(null, null, { ...user, max_budget: null })).toEqual([]);
expect(inheritedBudgetGates(null, null, null)).toEqual([]);
});
it("falls back to email then id for the user alias", () => {
expect(inheritedBudgetGates(null, null, { ...user, user_alias: null })[0].alias).toBe("owner@example.com");
expect(inheritedBudgetGates(null, null, { ...user, user_alias: null, user_email: null })[0].alias).toBe("user-1");
});
it("lists team, org, and user gates together", () => {
expect(inheritedBudgetGates(team, organization, user).map((g) => g.scope)).toEqual([
"Team",
"Organization",
"User",
]);
});
});
describe("InheritedBudgetHint", () => {
@ -57,4 +88,10 @@ describe("InheritedBudgetHint", () => {
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Organization Acme: $5,000.00");
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Organization Acme: $5,000.00 /");
});
it("shows the owner's user budget on hover", async () => {
render(<InheritedBudgetHint gates={inheritedBudgetGates(null, null, user)} />);
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("User Key Owner: $1,500.00 / 1mo");
});
});

View file

@ -6,7 +6,7 @@ import type { Organization } from "@/components/networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
export interface InheritedBudgetGate {
scope: "Team" | "Organization";
scope: "Team" | "Organization" | "User";
alias: string;
maxBudget: number;
budgetDuration: string | null;
@ -15,6 +15,14 @@ export interface InheritedBudgetGate {
type TeamBudgetSource = Pick<Team, "team_id" | "team_alias" | "max_budget" | "budget_duration">;
type OrganizationBudgetSource = Pick<Organization, "organization_id" | "organization_alias" | "litellm_budget_table">;
export interface UserBudgetSource {
user_id: string;
user_alias?: string | null;
user_email?: string | null;
max_budget?: number | null;
budget_duration?: string | null;
}
const teamGate = (team: TeamBudgetSource | null | undefined): InheritedBudgetGate | null =>
team && team.max_budget != null
? {
@ -38,10 +46,22 @@ const organizationGate = (organization: OrganizationBudgetSource | null | undefi
: null;
};
const userGate = (user: UserBudgetSource | null | undefined): InheritedBudgetGate | null =>
user && user.max_budget != null
? {
scope: "User",
alias: user.user_alias || user.user_email || user.user_id,
maxBudget: user.max_budget,
budgetDuration: user.budget_duration ?? null,
}
: null;
export const inheritedBudgetGates = (
team: TeamBudgetSource | null | undefined,
organization: OrganizationBudgetSource | null | undefined,
): readonly InheritedBudgetGate[] => [teamGate(team), organizationGate(organization)].filter((gate) => gate !== null);
user?: UserBudgetSource | null,
): readonly InheritedBudgetGate[] =>
[teamGate(team), organizationGate(organization), userGate(user)].filter((gate) => gate !== null);
const formatGate = (gate: InheritedBudgetGate): string =>
`${gate.scope} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`;

View file

@ -258,6 +258,71 @@ describe("KeyInfoView overview budget display (LIT-2845)", () => {
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("Team Org Team");
});
it("lists the owner's user budget in the hint for a personal key with no budget of its own", async () => {
renderWithProviders(
<KeyInfoView
keyData={
{
...MOCK_KEY_DATA,
max_budget: null,
team_id: null,
user: {
user_id: "user-1",
user_email: "owner@example.com",
user_alias: "Budget Owner",
max_budget: 1500,
budget_duration: "1mo",
},
} as unknown as KeyResponse
}
onClose={() => {}}
keyId={"test-key-id"}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
await waitFor(() => {
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("User Budget Owner: $1,500.00 / 1mo");
});
it("omits the owner's user budget from the hint for a team key", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-123", team_alias: "Test Budget", max_budget: 1200, budget_duration: "30d" })],
setTeams: vi.fn(),
});
renderWithProviders(
<KeyInfoView
keyData={
{
...MOCK_KEY_DATA,
max_budget: null,
team_id: "team-123",
user: {
user_id: "user-1",
user_email: "owner@example.com",
user_alias: "Budget Owner",
max_budget: 1500,
budget_duration: "1mo",
},
} as unknown as KeyResponse
}
onClose={() => {}}
keyId={"test-key-id"}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
await waitFor(() => {
expect(screen.getByText(/of Unlimited/)).toBeInTheDocument();
});
await userEvent.setup().hover(screen.getByLabelText("question-circle"));
expect(screen.getByTestId("inherited-budget-hint")).toHaveTextContent("Team Test Budget: $1,200.00 / 30d");
expect(screen.getByTestId("inherited-budget-hint")).not.toHaveTextContent("User Budget Owner");
});
it("renders 'Unlimited' with no hint when neither key, team, nor org has a budget", async () => {
vi.mocked(useTeams).mockReturnValue({
teams: [makeTeam({ team_id: "team-789", team_alias: "Free Team" })],

View file

@ -472,7 +472,8 @@ export default function KeyInfoView({
const hasOwnBudget = currentKeyData.max_budget !== null;
const budgetDisplay = hasOwnBudget ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited";
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg);
const ownerUser = currentKeyData.team_id ? null : currentKeyData.user;
const inheritedGates = hasOwnBudget ? [] : inheritedBudgetGates(parentTeam, parentOrg, ownerUser);
return (
<div className="w-full h-full overflow-y-auto p-4">