mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
feat(ui): budget period tooltip on key, team, user and org spend tiles (#48487)
* feat(ui): budget period tooltip on key, team, user and org spend tiles Adds a shared BudgetPeriodTooltip to the four detail-view spend tiles showing the current budget period, max budget, reset interval and spend so far, and makes organization create schedule budget_reset_at and organization info return it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): give the budget period tooltip trigger its own aria label The default SimpleTooltip trigger shares the question-circle label with InheritedBudgetHint, which broke the key budget display tests that count that icon Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): match the proxy's budget window rules in the period tooltip 30d and Nmo now span calendar months like get_budget_window_start, the arithmetic runs in UTC so a viewer's DST change cannot shift the start, the hourly/daily/weekly/monthly aliases the proxy accepts are normalized before parsing, and spend without a duration reads as lifetime spend Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: joshua <joshua@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
b9825e1f87
commit
1263f70ab6
12 changed files with 336 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<TabsContent value="overview" keepMounted>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card className="block p-6">
|
||||
<p>Spend</p>
|
||||
<p>
|
||||
Spend
|
||||
<BudgetPeriodTooltip
|
||||
spend={userData.spend}
|
||||
maxBudget={userData.max_budget}
|
||||
budgetDuration={userData.budget_duration}
|
||||
budgetResetAt={userData.budget_reset_at}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-lg font-medium">${formatNumberWithCommas(userData.spend || 0, 2)}</h3>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -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<OrganizationInfoProps> = ({
|
|||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Budget Status</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Budget Status
|
||||
<BudgetPeriodTooltip
|
||||
spend={orgData.spend}
|
||||
maxBudget={orgData.litellm_budget_table.max_budget}
|
||||
budgetDuration={orgData.litellm_budget_table.budget_duration}
|
||||
budgetResetAt={orgData.litellm_budget_table.budget_reset_at}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-foreground">
|
||||
<p className="text-xl font-semibold">${formatNumberWithCommas(orgData.spend, 4)}</p>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -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(<BudgetPeriodTooltip spend={1} maxBudget={10} budgetDuration="1d" budgetResetAt="2026-10-27T00:00:00Z" />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<div data-testid="budget-period-tooltip" className="flex flex-col gap-1">
|
||||
{budgetPeriodLines(props).map((line) => (
|
||||
<span key={line}>{line}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CircleHelp aria-label="budget-period-details" className="ml-1 size-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string> = new Set([
|
||||
"logging",
|
||||
|
|
@ -1375,7 +1376,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
children: (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card className="block p-6">
|
||||
<p>Budget Status</p>
|
||||
<p>
|
||||
Budget Status
|
||||
<BudgetPeriodTooltip
|
||||
spend={info.spend}
|
||||
maxBudget={info.max_budget}
|
||||
budgetDuration={info.budget_duration}
|
||||
budgetResetAt={info.budget_reset_at}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-lg font-medium">${formatNumberWithCommas(info.spend, 2)}</h3>
|
||||
<p>of {info.max_budget === null ? "Unlimited" : `$${formatNumberWithCommas(info.max_budget, 2)}`}</p>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<TabsContent value="overview" keepMounted>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card className="block p-6">
|
||||
<p className="text-sm">Spend</p>
|
||||
<p className="text-sm">
|
||||
Spend
|
||||
<BudgetPeriodTooltip
|
||||
spend={currentKeyData.spend}
|
||||
maxBudget={currentKeyData.max_budget}
|
||||
budgetDuration={currentKeyData.budget_duration}
|
||||
budgetResetAt={currentKeyData.budget_reset_at}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-lg font-medium">${formatNumberWithCommas(currentKeyData.spend, 4)}</h3>
|
||||
<p className="text-sm">
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
hourly: "1h",
|
||||
daily: "24h",
|
||||
weekly: "7d",
|
||||
monthly: "30d",
|
||||
};
|
||||
|
||||
const BUDGET_DURATION_UNITS: Record<string, dayjs.ManipulateType> = {
|
||||
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() };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue