diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled.ts new file mode 100644 index 00000000000..5b115750e21 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import { ConfigType, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig"; + +const FLAG_FIELD = "enable_ptu_cost_attribution"; + +export const useIsPtuCostAttributionEnabled = (): { enabled: boolean; isLoading: boolean } => { + const { data, isLoading } = useProxyConfig(ConfigType.GENERAL_SETTINGS); + + const enabled = useMemo(() => { + if (!data) { + return false; + } + const entry = data.find((item) => item.field_name === FLAG_FIELD); + return Boolean(entry?.field_value); + }, [data]); + + return { enabled, isLoading }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/ptuReservations/usePtuReservations.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/ptuReservations/usePtuReservations.ts new file mode 100644 index 00000000000..6cdf14daf87 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/ptuReservations/usePtuReservations.ts @@ -0,0 +1,77 @@ +import { useMutation, useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; +import { ptuReservationCloseCall, ptuReservationCreateCall, ptuReservationListCall } from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory"; + +export interface PtuReservationItem { + id: string; + team_id: string; + model: string; + cost_source: "manual" | "azure_billing"; + ptu_count: number | null; + cost_per_ptu: number | null; + azure_resource_id: string | null; + effective_from: string; + effective_to: string | null; + created_by: string; + created_at: string; + updated_by: string; + updated_at: string; +} + +export interface PtuReservationListFilters { + team_id?: string; + model?: string; + active_only?: boolean; +} + +export const ptuReservationKeys = createQueryKeys("ptuReservations"); + +export const usePtuReservations = ( + filters: PtuReservationListFilters = {}, + options: { enabled?: boolean } = {}, +): UseQueryResult => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: ptuReservationKeys.list(filters), + queryFn: async () => { + const data = await ptuReservationListCall(accessToken!, filters); + return (data ?? []).filter((item: PtuReservationItem | null): item is PtuReservationItem => item != null); + }, + enabled: Boolean(accessToken) && options.enabled !== false, + }); +}; + +export const useCreatePtuReservation = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation>({ + mutationFn: async (formValues) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return ptuReservationCreateCall(accessToken, formValues); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ptuReservationKeys.all }); + }, + }); +}; + +export const useClosePtuReservation = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, effective_to }) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return ptuReservationCloseCall(accessToken, id, effective_to); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ptuReservationKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/close_reservation_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/close_reservation_modal.tsx new file mode 100644 index 00000000000..66d4f1b36e2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/close_reservation_modal.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { Modal, Form, DatePicker, Button as Button2 } from "antd"; +import { PtuReservationItem, useClosePtuReservation } from "@/app/(dashboard)/hooks/ptuReservations/usePtuReservations"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +interface CloseReservationModalProps { + isModalVisible: boolean; + setIsModalVisible: React.Dispatch>; + reservation: PtuReservationItem | null; +} + +const CloseReservationModal: React.FC = ({ + isModalVisible, + setIsModalVisible, + reservation, +}) => { + const [form] = Form.useForm(); + const closeReservation = useClosePtuReservation(); + + const handleCancel = () => { + setIsModalVisible(false); + form.resetFields(); + }; + + const handleClose = async (formValues: Record) => { + if (!reservation) return; + try { + NotificationsManager.info("Closing reservation"); + await closeReservation.mutateAsync({ + id: reservation.id, + effective_to: formValues.effective_to?.toISOString(), + }); + NotificationsManager.success("Reservation closed"); + form.resetFields(); + setIsModalVisible(false); + } catch (error) { + console.error("Error closing PTU reservation:", error); + NotificationsManager.fromBackend(`Error closing reservation: ${error}`); + } + }; + + return ( + +

+ Closing sets effective_to so the reservation stops accruing daily flat cost. Historical rollup rows + already written are preserved for audit. +

+
+ + + + +
+ + Close reservation + +
+
+
+ ); +}; + +export default CloseReservationModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_modal.tsx new file mode 100644 index 00000000000..af387ecb8da --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_modal.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { Button as Button2, Modal, Form, Input, InputNumber, DatePicker } from "antd"; +import { useCreatePtuReservation } from "@/app/(dashboard)/hooks/ptuReservations/usePtuReservations"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +interface PtuReservationModalProps { + isModalVisible: boolean; + setIsModalVisible: React.Dispatch>; +} + +const PtuReservationModal: React.FC = ({ isModalVisible, setIsModalVisible }) => { + const [form] = Form.useForm(); + const createReservation = useCreatePtuReservation(); + + const handleCancel = () => { + setIsModalVisible(false); + form.resetFields(); + }; + + const handleCreate = async (formValues: Record) => { + try { + const body: Record = { + team_id: formValues.team_id, + model: formValues.model, + ptu_count: formValues.ptu_count, + cost_per_ptu: formValues.cost_per_ptu, + effective_from: formValues.effective_from?.toISOString(), + }; + if (formValues.effective_to) { + body.effective_to = formValues.effective_to.toISOString(); + } + NotificationsManager.info("Creating reservation"); + await createReservation.mutateAsync(body); + NotificationsManager.success("PTU reservation created"); + form.resetFields(); + setIsModalVisible(false); + } catch (error) { + console.error("Error creating PTU reservation:", error); + NotificationsManager.fromBackend(`Error creating PTU reservation: ${error}`); + } + }; + + return ( + +
+ + + + + + + + + + + + + + + + + + + +
+ + Create + +
+
+
+ ); +}; + +export default PtuReservationModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_panel.test.tsx new file mode 100644 index 00000000000..4624243dff7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_panel.test.tsx @@ -0,0 +1,136 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn().mockReturnValue({ + accessToken: "test-token", + userId: "test-user", + userRole: "proxy_admin", + }), +})); + +vi.mock("@/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled", () => ({ + useIsPtuCostAttributionEnabled: vi.fn().mockReturnValue({ enabled: true, isLoading: false }), +})); + +vi.mock("@/app/(dashboard)/hooks/ptuReservations/usePtuReservations", () => ({ + usePtuReservations: vi.fn().mockReturnValue({ data: [], isLoading: false }), + useClosePtuReservation: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), isPending: false }), + useCreatePtuReservation: vi.fn().mockReturnValue({ mutateAsync: vi.fn(), isPending: false }), +})); + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useIsPtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled"; +import { usePtuReservations } from "@/app/(dashboard)/hooks/ptuReservations/usePtuReservations"; +import PtuReservationPanel from "./ptu_reservation_panel"; + +function renderWithProviders(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render({ui}); +} + +const activeReservation = { + id: "res_1", + team_id: "team_x", + model: "gpt-4", + cost_source: "manual" as const, + ptu_count: 1, + cost_per_ptu: 200, + azure_resource_id: null, + effective_from: "2020-01-01T00:00:00Z", + effective_to: null, + created_by: "admin", + created_at: "2020-01-01T00:00:00Z", + updated_by: "admin", + updated_at: "2020-01-01T00:00:00Z", +}; + +describe("PtuReservationPanel", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.mocked(useAuthorized).mockReturnValue({ + accessToken: "test-token", + userId: "test-user", + userRole: "proxy_admin", + } as any); + }); + + it("shows disabled state when feature flag is off", () => { + vi.mocked(useIsPtuCostAttributionEnabled).mockReturnValue({ enabled: false, isLoading: false }); + + renderWithProviders(); + + expect(screen.getByText(/PTU cost attribution is disabled/i)).toBeInTheDocument(); + expect(screen.queryByText(/Create Reservation/i)).not.toBeInTheDocument(); + }); + + it("shows loading state while flag is being fetched", () => { + vi.mocked(useIsPtuCostAttributionEnabled).mockReturnValue({ enabled: false, isLoading: true }); + + renderWithProviders(); + + expect(screen.getByText(/Loading/i)).toBeInTheDocument(); + }); + + it("renders empty state when no reservations exist", async () => { + vi.mocked(useIsPtuCostAttributionEnabled).mockReturnValue({ enabled: true, isLoading: false }); + vi.mocked(usePtuReservations).mockReturnValue({ data: [], isLoading: false } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/No reservations yet/i)).toBeInTheDocument(); + }); + }); + + it("renders reservations in the table with correct fields", async () => { + vi.mocked(useIsPtuCostAttributionEnabled).mockReturnValue({ enabled: true, isLoading: false }); + vi.mocked(usePtuReservations).mockReturnValue({ + data: [activeReservation], + isLoading: false, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("team_x")).toBeInTheDocument(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + // status badge + expect(screen.getByText("active")).toBeInTheDocument(); + }); + + it("opens the create modal from the button", async () => { + vi.mocked(useIsPtuCostAttributionEnabled).mockReturnValue({ enabled: true, isLoading: false }); + vi.mocked(usePtuReservations).mockReturnValue({ data: [], isLoading: false } as any); + + renderWithProviders(); + + // Modal must be closed initially — no "Team ID" form label visible + expect(screen.queryByLabelText(/Team ID/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("+ Create Reservation")); + await waitFor(() => { + expect(screen.getByLabelText(/Team ID/i)).toBeInTheDocument(); + }); + }); + + it("shows a friendly denial for non-admin roles", async () => { + vi.mocked(useAuthorized).mockReturnValue({ + accessToken: "t", + userId: "u", + userRole: "internal_user", + } as any); + vi.mocked(useIsPtuCostAttributionEnabled).mockReturnValue({ enabled: true, isLoading: false }); + vi.mocked(usePtuReservations).mockReturnValue({ data: [], isLoading: false } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/proxy-admin access/i)).toBeInTheDocument(); + }); + expect(screen.queryByText("+ Create Reservation")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_panel.tsx new file mode 100644 index 00000000000..e769cd44e08 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/_components/ptu_reservation_panel.tsx @@ -0,0 +1,216 @@ +import React, { useState } from "react"; +import { Button, Card, Tag, Typography } from "antd"; +import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { MoneyCell } from "@/components/shared/table_cells"; +import { useIsPtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled"; +import { + PtuReservationItem, + usePtuReservations, +} from "@/app/(dashboard)/hooks/ptuReservations/usePtuReservations"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { isProxyAdminRole } from "@/utils/roles"; +import PtuReservationModal from "./ptu_reservation_modal"; +import CloseReservationModal from "./close_reservation_modal"; + +const { Title, Text } = Typography; + +interface PtuReservationPanelProps { + accessToken: string | null; +} + +type ReservationStatus = "active" | "scheduled" | "ended"; + +function reservationStatus(row: PtuReservationItem, now: Date): ReservationStatus { + const from = new Date(row.effective_from).getTime(); + const to = row.effective_to ? new Date(row.effective_to).getTime() : null; + const t = now.getTime(); + if (from > t) return "scheduled"; + if (to !== null && to <= t) return "ended"; + return "active"; +} + +function statusColor(status: ReservationStatus): string { + if (status === "active") return "green"; + if (status === "scheduled") return "blue"; + return "default"; +} + +function monthlyTotal(row: PtuReservationItem): number | null { + if (row.ptu_count == null || row.cost_per_ptu == null) return null; + return row.ptu_count * row.cost_per_ptu; +} + +const PtuReservationPanel: React.FC = ({ accessToken }) => { + const [isCreateVisible, setIsCreateVisible] = useState(false); + const [isCloseVisible, setIsCloseVisible] = useState(false); + const [selected, setSelected] = useState(null); + + const { userRole } = useAuthorized(); + const canModify = isProxyAdminRole(userRole ?? ""); + + const { enabled: featureEnabled, isLoading: flagLoading } = useIsPtuCostAttributionEnabled(); + const { data: reservations = [], isLoading } = usePtuReservations({}, { enabled: featureEnabled }); + + if (flagLoading) { + return ( +
+ + Loading… + +
+ ); + } + + if (!featureEnabled) { + return ( +
+ + PTU Reservations + + PTU cost attribution is disabled. Set enable_ptu_cost_attribution: true in{" "} + general_settings to use this feature. + + +
+ ); + } + + if (!canModify) { + return ( +
+ + PTU Reservations + You need proxy-admin access to view PTU reservations. + +
+ ); + } + + const handleCloseClick = (row: PtuReservationItem) => { + setSelected(row); + setIsCloseVisible(true); + }; + + const now = new Date(); + const sortedReservations = reservations + .slice() + .sort((a, b) => new Date(b.effective_from).getTime() - new Date(a.effective_from).getTime()); + + return ( +
+ {accessToken && ( + + )} + + PTU Reservations + + Register a flat prepaid cost for a (team, model) window. The daily rollup writes prorated flat cost to each + day's team spend row. + + {renderTable(isLoading, sortedReservations, now, handleCloseClick)} + + + + +
+ ); +}; + +function renderTable( + isLoading: boolean, + rows: PtuReservationItem[], + now: Date, + handleCloseClick: (r: PtuReservationItem) => void, +) { + if (isLoading) { + return Loading reservations…; + } + if (rows.length === 0) { + return ( + + No reservations yet. Click "Create Reservation" to add one. + + ); + } + return ( + + + + + + + + + + + + + + + + {rows.map((row) => { + const status = reservationStatus(row, now); + return ( + + + + + + + + + + + + ); + })} + +
TeamModelPTU countCost / PTUMonthly totalEffective fromEffective toStatusActions
{row.team_id}{row.model}{row.ptu_count ?? "—"} + + + + {new Date(row.effective_from).toISOString()}{row.effective_to ? new Date(row.effective_to).toISOString() : "—"} + {status} + {renderActionCell(status, row, handleCloseClick)}
+ ); +} + +function renderActionCell( + status: ReservationStatus, + row: PtuReservationItem, + handleCloseClick: (r: PtuReservationItem) => void, +) { + if (status === "ended") { + return —; + } + return ( + handleCloseClick(row)} + dataTestId="close-ptu-reservation-button" + /> + ); +} + +const cellHead: React.CSSProperties = { + textAlign: "left", + padding: "8px", + borderBottom: "1px solid #e5e7eb", + fontWeight: 600, + fontSize: 12, +}; + +const cellBody: React.CSSProperties = { + padding: "8px", + borderBottom: "1px solid #f3f4f6", + fontSize: 13, +}; + +export default PtuReservationPanel; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/page.tsx new file mode 100644 index 00000000000..ee46f66ad59 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ptu-reservations/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import PtuReservationPanel from "./_components/ptu_reservation_panel"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function PtuReservations() { + const { accessToken } = useAuthorized(); + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index a5e0488cbb9..42128ac137b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -49,6 +49,10 @@ vi.mock("@/components/common_components/team_multi_select", () => ({ default: () =>
Team Multi Select
, })); +vi.mock("@/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled", () => ({ + useIsPtuCostAttributionEnabled: () => ({ enabled: false, isLoading: false }), +})); + // Mock useTeams hook vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(() => ({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 94a9cedbdf5..19f26f08712 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -40,6 +40,7 @@ import { } from "@/components/networking"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; +import { useIsPtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled"; import { BreakdownMetrics, DailyData, @@ -75,6 +76,7 @@ interface EntitySpendData { results: ExtendedDailyData[]; metadata: { total_spend: number; + total_flat_cost?: number; total_api_requests: number; total_successful_requests: number; total_failed_requests: number; @@ -114,6 +116,9 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti const [topModelsLimit, setTopModelsLimit] = useState(5); const [topAgentsLimit, setTopAgentsLimit] = useState(5); + const { enabled: ptuAttributionEnabled } = useIsPtuCostAttributionEnabled(); + const showFlatCost = entityType === "team" && ptuAttributionEnabled; + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); @@ -514,13 +519,33 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti {capitalizedEntityLabel} Spend Overview - + Total Spend ${formatNumberWithCommas(spendData.metadata.total_spend, 2)} + {showFlatCost && ( + <> + + Flat Cost + + ${formatNumberWithCommas(spendData.metadata.total_flat_cost ?? 0, 2)} + + + + Total Cost + + $ + {formatNumberWithCommas( + spendData.metadata.total_spend + (spendData.metadata.total_flat_cost ?? 0), + 2, + )} + + + + )} Total Requests diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 0f92b61a46e..d0c3235c4e8 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -9,6 +9,7 @@ export interface EntitySpendData { results: any[]; metadata: { total_spend: number; + total_flat_cost?: number; total_api_requests: number; total_successful_requests: number; total_failed_requests: number; @@ -38,6 +39,8 @@ export interface ExportMetadata { export_scope: ExportScope; summary: { total_spend: number; + total_flat_cost?: number; + total_cost?: number; total_requests: number; successful_requests: number; failed_requests: number; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index dfeecdc4fa6..b519483771b 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1861,6 +1861,89 @@ describe("EntityUsageExport utils", () => { expect(result.summary.failed_requests).toBe(20); expect(result.summary.total_tokens).toBe(4500); }); + + it("should include total_flat_cost and total_cost in team summary when provided", () => { + const teamSpendWithFlat: EntitySpendData = { + ...mockSpendData, + metadata: { ...mockSpendData.metadata, total_flat_cost: 6.45 }, + }; + const result = generateMetadata("team", mockDateRange, [], "daily", teamSpendWithFlat); + expect(result.summary.total_flat_cost).toBeCloseTo(6.45, 4); + expect(result.summary.total_cost).toBeCloseTo(46.0 + 6.45, 4); + }); + + it("should omit total_flat_cost and total_cost from non-team summary", () => { + const teamSpendWithFlat: EntitySpendData = { + ...mockSpendData, + metadata: { ...mockSpendData.metadata, total_flat_cost: 6.45 }, + }; + const result = generateMetadata("user", mockDateRange, [], "daily", teamSpendWithFlat); + expect(result.summary.total_flat_cost).toBeUndefined(); + expect(result.summary.total_cost).toBeUndefined(); + }); + }); + + describe("generateDailyData PTU flat cost", () => { + const dayWithFlat: EntitySpendData = { + results: [ + { + date: "2025-01-01", + breakdown: { + entities: { + "team-1": { + metrics: { + spend: 10, + flat_cost: 6.45, + api_requests: 50, + successful_requests: 50, + failed_requests: 0, + total_tokens: 500, + prompt_tokens: 300, + completion_tokens: 200, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + api_key_breakdown: {}, + }, + }, + }, + }, + ], + metadata: { + total_spend: 10, + total_flat_cost: 6.45, + total_api_requests: 50, + total_successful_requests: 50, + total_failed_requests: 0, + total_tokens: 500, + }, + }; + + it("includes Flat Cost ($) and Total Cost ($) columns when total_flat_cost is present", () => { + const rows = generateDailyData(dayWithFlat, "Team", {}); + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveProperty("Flat Cost ($)"); + expect(rows[0]).toHaveProperty("Total Cost ($)"); + expect(rows[0]["Flat Cost ($)"]).toBe("6.4500"); + expect(rows[0]["Total Cost ($)"]).toBe("16.4500"); + }); + + it("does not include Flat Cost / Total Cost columns when total_flat_cost is absent", () => { + const spendWithoutFlat: EntitySpendData = { + ...dayWithFlat, + metadata: { + total_spend: 10, + total_api_requests: 50, + total_successful_requests: 50, + total_failed_requests: 0, + total_tokens: 500, + }, + }; + const rows = generateDailyData(spendWithoutFlat, "User", {}); + expect(rows).toHaveLength(1); + expect(rows[0]).not.toHaveProperty("Flat Cost ($)"); + expect(rows[0]).not.toHaveProperty("Total Cost ($)"); + }); }); describe("handleExportCSV", () => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 8ff389ded5e..693736f00d5 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -104,31 +104,44 @@ export const getEntityBreakdown = ( return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); }; +// PTU flat cost is only surfaced on the team daily-activity response. Every other +// entity's metadata omits total_flat_cost, so its presence is the signal for +// including the extra columns. +const hasFlatCost = (spendData: EntitySpendData): boolean => + spendData.metadata.total_flat_cost !== undefined && spendData.metadata.total_flat_cost !== null; + export const generateDailyData = ( spendData: EntitySpendData, entityLabel: string, teamAliasMap: Record = {}, ): any[] => { const dailyBreakdown: any[] = []; + const includeFlatCost = hasFlatCost(spendData); spendData.results.forEach((day) => { Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => { const { id, alias } = resolveEntityDisplay(entity, teamAliasMap); - dailyBreakdown.push({ + const row: Record = { Date: day.date, [entityLabel]: alias, [`${entityLabel} ID`]: id, "Spend ($)": formatNumberWithCommas(data.metrics.spend, 4), - Requests: data.metrics.api_requests, - "Successful Requests": data.metrics.successful_requests, - "Failed Requests": data.metrics.failed_requests, - "Total Tokens": data.metrics.total_tokens, - "Prompt Tokens": data.metrics.prompt_tokens || 0, - "Completion Tokens": data.metrics.completion_tokens || 0, - "Cache Read Input Tokens": data.metrics.cache_read_input_tokens || 0, - "Cache Creation Input Tokens": data.metrics.cache_creation_input_tokens || 0, - }); + }; + if (includeFlatCost) { + const flatCost = data.metrics.flat_cost || 0; + row["Flat Cost ($)"] = formatNumberWithCommas(flatCost, 4); + row["Total Cost ($)"] = formatNumberWithCommas((data.metrics.spend || 0) + flatCost, 4); + } + row.Requests = data.metrics.api_requests; + row["Successful Requests"] = data.metrics.successful_requests; + row["Failed Requests"] = data.metrics.failed_requests; + row["Total Tokens"] = data.metrics.total_tokens; + row["Prompt Tokens"] = data.metrics.prompt_tokens || 0; + row["Completion Tokens"] = data.metrics.completion_tokens || 0; + row["Cache Read Input Tokens"] = data.metrics.cache_read_input_tokens || 0; + row["Cache Creation Input Tokens"] = data.metrics.cache_creation_input_tokens || 0; + dailyBreakdown.push(row); }); }); @@ -331,23 +344,31 @@ export const generateMetadata = ( selectedFilters: string[], exportScope: ExportScope, spendData: EntitySpendData, -): ExportMetadata => ({ - export_date: new Date().toISOString(), - entity_type: entityType, - date_range: { - from: dateRange.from?.toISOString(), - to: dateRange.to?.toISOString(), - }, - filters_applied: selectedFilters.length > 0 ? selectedFilters : "None", - export_scope: exportScope, - summary: { +): ExportMetadata => { + const summary: Record = { total_spend: spendData.metadata.total_spend, total_requests: spendData.metadata.total_api_requests, successful_requests: spendData.metadata.total_successful_requests, failed_requests: spendData.metadata.total_failed_requests, total_tokens: spendData.metadata.total_tokens, - }, -}); + }; + if (entityType === "team") { + const flatCost = spendData.metadata.total_flat_cost ?? 0; + summary.total_flat_cost = flatCost; + summary.total_cost = spendData.metadata.total_spend + flatCost; + } + return { + export_date: new Date().toISOString(), + entity_type: entityType, + date_range: { + from: dateRange.from?.toISOString(), + to: dateRange.to?.toISOString(), + }, + filters_applied: selectedFilters.length > 0 ? selectedFilters : "None", + export_scope: exportScope, + summary, + }; +}; export const handleExportCSV = ( spendData: EntitySpendData, diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index 6c1297613fa..bef905efc4e 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -1,5 +1,6 @@ export interface SpendMetrics { spend: number; + flat_cost?: number; prompt_tokens: number; completion_tokens: number; total_tokens: number; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index bf3440e7ab9..81c3f4d4f6a 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -60,6 +60,7 @@ import { Wallet, Wrench, Workflow, + Coins, } from "lucide-react"; import Link from "next/link"; import { useMemo, useState } from "react"; @@ -233,6 +234,13 @@ const menuGroups: MenuGroup[] = [ roles: all_admin_roles, }, { key: "budgets", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, + { + key: "ptu-reservations", + page: "ptu-reservations", + label: "PTU Reservations", + icon: , + roles: all_admin_roles, + }, ], }, { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 7e3af46b931..43679f723a6 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -645,6 +645,53 @@ export const budgetUpdateCall = async ( } }; +export const ptuReservationListCall = async ( + accessToken: string, + filters?: { team_id?: string; model?: string; active_only?: boolean }, +) => { + try { + const params = new URLSearchParams(); + if (filters?.team_id) params.set("team_id", filters.team_id); + if (filters?.model) params.set("model", filters.model); + if (filters?.active_only) params.set("active_only", "true"); + const query = params.toString(); + const path = query ? `/ptu_reservation/list?${query}` : `/ptu_reservation/list`; + return await apiClient.get(path, { accessToken }); + } catch (error) { + console.error("Failed to list PTU reservations:", error); + throw error; + } +}; + +export const ptuReservationInfoCall = async (accessToken: string, id: string) => { + try { + return await apiClient.get(`/ptu_reservation/info?id=${encodeURIComponent(id)}`, { accessToken }); + } catch (error) { + console.error("Failed to fetch PTU reservation:", error); + throw error; + } +}; + +export const ptuReservationCreateCall = async (accessToken: string, formValues: Record) => { + try { + return await apiClient.post(`/ptu_reservation/new`, { accessToken, body: { ...formValues } }); + } catch (error) { + console.error("Failed to create PTU reservation:", error); + throw error; + } +}; + +export const ptuReservationCloseCall = async (accessToken: string, id: string, effective_to?: string) => { + try { + const body: Record = { id }; + if (effective_to) body.effective_to = effective_to; + return await apiClient.post(`/ptu_reservation/close`, { accessToken, body }); + } catch (error) { + console.error("Failed to close PTU reservation:", error); + throw error; + } +}; + export const invitationCreateCall = async ( accessToken: string, userID: string, // Assuming formValues is an object diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 1b0734eb220..ee86e4b8187 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -28,6 +28,7 @@ export const pageDescriptions: Record = { projects: "Manage projects within teams", "access-groups": "Manage access groups for role-based permissions", budgets: "Set and monitor spending budgets", + "ptu-reservations": "Manage Azure PTU reservations and flat cost attribution", api_ref: "Browse API documentation and endpoints", "model-hub-table": "Explore available AI models and providers", "learning-resources": "Access tutorials and documentation", diff --git a/ui/litellm-dashboard/src/utils/migratedPages.ts b/ui/litellm-dashboard/src/utils/migratedPages.ts index b0dfb46e312..57d094a606d 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.ts @@ -19,6 +19,7 @@ export const MIGRATED_PAGES: Record = { chat: "chat", "access-groups": "access-groups", budgets: "budgets", + "ptu-reservations": "ptu-reservations", workflows: "workflows", "guardrails-monitor": "guardrails-monitor", "mcp-servers": "mcp-servers",