diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6280e2caf87..49d23d82aa9 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3277,7 +3277,7 @@ class LiteLLM_OrganizationTableWithMembers(LiteLLM_OrganizationTable): members: list[LiteLLM_OrganizationMembershipTable] = [] teams: list[LiteLLM_TeamTable] = [] - litellm_budget_table: LiteLLM_BudgetTable | None = None + litellm_budget_table: LiteLLM_BudgetTableFull | None = None created_at: datetime updated_at: datetime diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index d7a44cdd0b8..e1d19d8efae 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -504,9 +504,8 @@ async def new_organization( _budget: Final = await _table(BudgetRepository(prisma_client)).create( data={ - **new_budget, + **build_budget_write_data(new_budget, user_api_key_dict.user_id or litellm_proxy_admin_name), "created_by": user_api_key_dict.user_id or litellm_proxy_admin_name, - "updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name, } ) diff --git a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py index a5cae02ea10..16c59aa20b9 100644 --- a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py @@ -1615,3 +1615,68 @@ async def test_delete_organization_is_refused_while_one_of_its_teams_holds_a_ptu prisma_client.db.litellm_teamtable.delete_many.assert_not_called() prisma_client.db.litellm_verificationtoken.delete_many.assert_not_called() prisma_client.db.litellm_organizationtable.delete.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("budget_duration", ["30d", None]) +async def test_new_organization_schedules_budget_reset_from_duration(monkeypatch, budget_duration): + """/organization/new with an inline budget_duration must write budget_reset_at on the auto-created + budget row, the way team create and /budget/new do, or the org budget never resets and the UI + cannot show the current budget period.""" + from datetime import datetime + + from litellm.proxy._types import LitellmUserRoles, NewOrganizationRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.organization_endpoints import new_organization + from litellm.proxy.utils import PrismaClient + + prisma_client = MagicMock() + prisma_client.jsonify_object = MagicMock(side_effect=lambda data: PrismaClient.jsonify_object(prisma_client, data)) + prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + prisma_client.db.litellm_budgettable.create = AsyncMock(return_value=MagicMock(budget_id="budget-1")) + prisma_client.db.litellm_organizationtable.create = AsyncMock(return_value={"organization_id": "org-1"}) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", MagicMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True, raising=False) + + await new_organization( + data=NewOrganizationRequest(organization_alias="org", max_budget=100, budget_duration=budget_duration), + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + budget_write = prisma_client.db.litellm_budgettable.create.await_args.kwargs["data"] + if budget_duration is None: + assert "budget_reset_at" not in budget_write, budget_write + return + reset_at = budget_write["budget_reset_at"] + assert isinstance(reset_at, datetime) and reset_at.tzinfo is not None, budget_write + assert budget_write["budget_duration"] == budget_duration + + +def test_organization_info_response_keeps_budget_reset_at(): + """/organization/info must return the budget row's budget_reset_at so the dashboard can show + which budget period the org's spend covers.""" + from datetime import datetime, timezone + + from litellm.proxy._types import LiteLLM_OrganizationTableWithMembers + + reset_at = datetime(2026, 11, 1, tzinfo=timezone.utc) + created_at = datetime(2026, 10, 1, tzinfo=timezone.utc) + response = LiteLLM_OrganizationTableWithMembers( + organization_id="org-1", + organization_alias="org", + budget_id="budget-1", + models=[], + created_by="admin", + updated_by="admin", + created_at=created_at, + updated_at=created_at, + litellm_budget_table={ + "budget_id": "budget-1", + "max_budget": 100.0, + "budget_duration": "30d", + "budget_reset_at": reset_at, + "created_at": created_at, + }, + ) + + assert response.model_dump()["litellm_budget_table"]["budget_reset_at"] == reset_at diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx index 2056142b50a..476c043a258 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.tsx @@ -47,6 +47,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import ScopedSavingsTab from "@/components/shared/ScopedSavingsTab"; import { AutoRouterUsageView } from "@/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab"; import { useActivityDateRange } from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange"; +import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip"; interface UserInfoViewProps { userId: string; @@ -507,7 +508,15 @@ export default function UserInfoView({
-

Spend

+

+ Spend + +

${formatNumberWithCommas(userData.spend || 0, 2)}

diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index f325e92d2b5..2be549261c3 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -27,6 +27,7 @@ import ObjectPermissionsView from "../object_permissions_view"; import MemberModal from "../team/EditMembership"; import { OrgSettingsForm } from "./org-settings/OrgSettingsForm"; import { ORGANIZATION_TAB_URL_KEY, ORGANIZATION_TABS, type OrganizationTab } from "./organizationTabs"; +import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip"; interface OrganizationInfoProps { organizationId: string; @@ -193,7 +194,15 @@ const OrganizationInfoView: React.FC = ({ -

Budget Status

+

+ Budget Status + +

${formatNumberWithCommas(orgData.spend, 4)}

diff --git a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx new file mode 100644 index 00000000000..4d77218a776 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { BudgetPeriodTooltip, budgetPeriodLines } from "./BudgetPeriodTooltip"; + +const originalTimezone: string | undefined = process.env.TZ; + +afterEach(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + return; + } + process.env.TZ = originalTimezone; +}); + +const scheduled = { spend: 12.5, maxBudget: 100, budgetDuration: "30d", budgetResetAt: "2026-04-01T00:00:00Z" }; +const unparseableDuration = { ...scheduled, spend: 0, budgetDuration: "fortnight" }; +const unscheduled = { spend: 0, maxBudget: 100, budgetDuration: "30d", budgetResetAt: null }; +const lifetime = { spend: 3, maxBudget: 50, budgetDuration: null, budgetResetAt: null }; +const noBudget = { spend: null, maxBudget: null, budgetDuration: "30d", budgetResetAt: null }; +const noBudgetNoDuration = { spend: 2, maxBudget: null, budgetDuration: null, budgetResetAt: null }; + +describe("budgetPeriodLines", () => { + it("describes the current period, max budget, interval and spend when a reset is scheduled", () => { + process.env.TZ = "UTC"; + expect(budgetPeriodLines(scheduled)).toEqual([ + "Budget period: Mar 1, 2026 00:00 to Apr 1, 2026 00:00 (UTC)", + "Max budget: $100.00", + "Reset interval: monthly", + "Spend this period: $12.5000", + ]); + }); + + it("falls back to the next reset when the duration cannot be parsed", () => { + process.env.TZ = "UTC"; + expect(budgetPeriodLines(unparseableDuration)).toEqual([ + "Next reset: Apr 1, 2026 00:00 (UTC)", + "Max budget: $100.00", + "Reset interval: fortnight", + "Spend this period: $0.0000", + ]); + }); + + it("omits the period line when no reset has been scheduled yet", () => { + expect(budgetPeriodLines(unscheduled)).toEqual([ + "Max budget: $100.00", + "Reset interval: monthly", + "Spend this period: $0.0000", + ]); + }); + + it("reports a lifetime budget when there is no duration", () => { + expect(budgetPeriodLines(lifetime)).toEqual([ + "Lifetime budget, never resets", + "Max budget: $50.00", + "Spend so far: $3.0000", + ]); + }); + + it("reports no budget when max budget is unset", () => { + expect(budgetPeriodLines(noBudget)).toEqual(["No budget set", "Spend this period: $0.0000"]); + }); + + it("uses lifetime wording when there is neither a budget nor a duration", () => { + expect(budgetPeriodLines(noBudgetNoDuration)).toEqual(["No budget set", "Spend so far: $2.0000"]); + }); +}); + +describe("BudgetPeriodTooltip", () => { + it("shows the budget period details on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByLabelText("budget-period-details")); + + const tooltip = await screen.findByTestId("budget-period-tooltip"); + expect(tooltip).toHaveTextContent("Max budget: $10.00"); + expect(tooltip).toHaveTextContent("Reset interval: 1d"); + expect(tooltip).toHaveTextContent("Spend this period: $1.0000"); + expect(tooltip).not.toHaveTextContent("Invalid Date"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx new file mode 100644 index 00000000000..56c0667b2a5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/BudgetPeriodTooltip.tsx @@ -0,0 +1,65 @@ +"use client"; + +import dayjs from "dayjs"; +import { CircleHelp } from "lucide-react"; + +import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { budgetPeriodRange } from "@/utils/budgetUtils"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +export interface BudgetPeriodTooltipProps { + spend: number | null | undefined; + maxBudget: number | null | undefined; + budgetDuration: string | null | undefined; + budgetResetAt: string | null | undefined; +} + +const formatPeriodDate = (iso: string): string => dayjs(iso).format("MMM D, YYYY HH:mm"); + +const describePeriod = (budgetDuration: string, budgetResetAt: string | null | undefined): string | null => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const period = budgetPeriodRange(budgetDuration, budgetResetAt); + if (period) { + return `Budget period: ${formatPeriodDate(period.start)} to ${formatPeriodDate(period.end)} (${timeZone})`; + } + if (budgetResetAt && dayjs(budgetResetAt).isValid()) { + return `Next reset: ${formatPeriodDate(budgetResetAt)} (${timeZone})`; + } + return null; +}; + +export const budgetPeriodLines = ({ + spend, + maxBudget, + budgetDuration, + budgetResetAt, +}: BudgetPeriodTooltipProps): readonly string[] => { + const spendLine = `${budgetDuration ? "Spend this period" : "Spend so far"}: $${formatNumberWithCommas(spend ?? 0, 4)}`; + if (maxBudget == null) return ["No budget set", spendLine]; + const maxBudgetLine = `Max budget: $${formatNumberWithCommas(maxBudget, 2)}`; + if (!budgetDuration) return ["Lifetime budget, never resets", maxBudgetLine, spendLine]; + const periodLine = describePeriod(budgetDuration, budgetResetAt); + return [ + ...(periodLine ? [periodLine] : []), + maxBudgetLine, + `Reset interval: ${getBudgetDurationLabel(budgetDuration)}`, + spendLine, + ]; +}; + +export function BudgetPeriodTooltip(props: BudgetPeriodTooltipProps) { + return ( + + {budgetPeriodLines(props).map((line) => ( + {line} + ))} +

+ } + > + + + ); +} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 0d63f5d0833..0d20d160283 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -135,6 +135,7 @@ import ResetMemberBudgetsDialog from "./ResetMemberBudgetsDialog"; import { customBudgetMemberUserIds, shouldPromptMemberBudgetReset } from "./memberBudgetReset"; import { useMemberBudgetReset } from "./useMemberBudgetReset"; import { fetchClient } from "@/lib/http/api"; +import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip"; const UI_MANAGED_METADATA_KEYS: ReadonlySet = new Set([ "logging", @@ -1375,7 +1376,15 @@ const TeamInfoView: React.FC = ({ children: (
-

Budget Status

+

+ Budget Status + +

${formatNumberWithCommas(info.spend, 2)}

of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 2)}`}

diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 7b924243fb5..4680368943a 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -57,6 +57,7 @@ import { } from "../shared/InheritedBudgetHint"; import { KeyEditView } from "./key_edit_view"; import { isTeamAdminEditingMemberKey, teamAdminMemberKeyPayload } from "./teamAdminMemberKeyPayload"; +import { BudgetPeriodTooltip } from "@/components/shared/BudgetPeriodTooltip"; export function needsLifetimeSpendBackfill(spend: number, totalSpend: number | null | undefined): boolean { return (totalSpend ?? 0) < spend; @@ -722,7 +723,15 @@ export default function KeyInfoView({
-

Spend

+

+ Spend + +

${formatNumberWithCommas(currentKeyData.spend, 4)}

diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d3c1e0422df..ababe4507b8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -35066,7 +35066,7 @@ export interface components { created_at: string; /** Created By */ created_by: string; - litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTable"] | null; + litellm_budget_table?: components["schemas"]["LiteLLM_BudgetTableFull"] | null; /** * Members * @default [] diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts index a3b5f4da00a..e33c779bb65 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils"; +import { budgetPeriodRange, describeBudgetResetSchedule, formatBudgetReset, formatBudgetResetUtc } from "./budgetUtils"; const originalTimezone: string | undefined = process.env.TZ; @@ -44,3 +44,41 @@ describe("describeBudgetResetSchedule", () => { expect(schedule.tooltip).toContain("carries into the next cycle"); }); }); + +describe("budgetPeriodRange", () => { + const resetAt = "2026-04-01T00:00:00Z"; + + it.each([ + ["30d", "2026-03-01T00:00:00.000Z"], + ["monthly", "2026-03-01T00:00:00.000Z"], + ["1mo", "2026-03-01T00:00:00.000Z"], + ["2mo", "2026-02-01T00:00:00.000Z"], + ["7d", "2026-03-25T00:00:00.000Z"], + ["weekly", "2026-03-25T00:00:00.000Z"], + ["1w", "2026-03-25T00:00:00.000Z"], + ["15d", "2026-03-17T00:00:00.000Z"], + ["24h", "2026-03-31T00:00:00.000Z"], + ["daily", "2026-03-31T00:00:00.000Z"], + ["1h", "2026-03-31T23:00:00.000Z"], + ["hourly", "2026-03-31T23:00:00.000Z"], + ])("computes the period start for %s the way the proxy does", (duration, start) => { + expect(budgetPeriodRange(duration, resetAt)).toEqual({ start, end: "2026-04-01T00:00:00.000Z" }); + }); + + it("spans a calendar month for 30d even when the viewer is in a DST-shifting timezone", () => { + process.env.TZ = "America/New_York"; + expect(budgetPeriodRange("30d", "2026-04-01T00:00:00Z")?.start).toBe("2026-03-01T00:00:00.000Z"); + expect(budgetPeriodRange("30d", "2026-03-01T00:00:00Z")?.start).toBe("2026-02-01T00:00:00.000Z"); + }); + + it.each([ + ["bogus", resetAt], + ["0d", resetAt], + ["30d", "not-a-date"], + ["30d", null], + [null, resetAt], + ["", resetAt], + ])("returns null for duration %s and reset %s instead of an invalid date", (duration, reset) => { + expect(budgetPeriodRange(duration, reset)).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/budgetUtils.ts b/ui/litellm-dashboard/src/utils/budgetUtils.ts index 171ce5818f7..23af15eb539 100644 --- a/ui/litellm-dashboard/src/utils/budgetUtils.ts +++ b/ui/litellm-dashboard/src/utils/budgetUtils.ts @@ -54,3 +54,44 @@ export function describeBudgetResetSchedule( tooltip: `Resets every ${budgetDuration}, but the next reset time has not been scheduled yet. ${ROLLOVER_NOTE}`, }; } + +const BUDGET_DURATION_WORD_ALIASES: Record = { + hourly: "1h", + daily: "24h", + weekly: "7d", + monthly: "30d", +}; + +const BUDGET_DURATION_UNITS: Record = { + s: "second", + m: "minute", + h: "hour", + d: "day", + w: "week", + mo: "month", +}; + +export interface BudgetPeriod { + start: string; + end: string; +} + +/** Mirrors the proxy's get_budget_window_start: `30d` and `Nmo` span calendar months, the rest are fixed deltas. */ +export function budgetPeriodRange( + budgetDuration: string | null | undefined, + budgetResetAt: string | null | undefined, +): BudgetPeriod | null { + if (!budgetDuration || !budgetResetAt) return null; + const end = dayjs.utc(budgetResetAt); + if (!end.isValid()) return null; + const normalized = budgetDuration.trim().toLowerCase(); + const match = /^(\d+)(mo|s|m|h|d|w)$/.exec(BUDGET_DURATION_WORD_ALIASES[normalized] ?? normalized); + if (!match) return null; + const amount = Number(match[1]); + if (amount <= 0) return null; + const start = + match[2] === "d" && amount === 30 + ? end.subtract(1, "month") + : end.subtract(amount, BUDGET_DURATION_UNITS[match[2]]); + return { start: start.toISOString(), end: end.toISOString() }; +}