feat(ptu): admin UI for PTU reservations + Usage page column + CSV export

Adds three UI surfaces gated by enable_ptu_cost_attribution:

1. PTU Reservations admin page under /ptu-reservations. Feature-flagged
   panel with a list table + create modal + close modal. Only proxy admin
   can create/close; non-admin sees a friendly denial. Uses antd
   components (tremor phase-out) and TableIconActionButton for row actions.
2. Usage page > Team view: adds Flat Cost and Total Cost stat cards next
   to Total Spend when the flag is on. Non-team entities are unaffected;
   grid reflows from 5 to 7 items.
3. EntityUsageExport: CSV daily export gains Flat Cost ($) and
   Total Cost ($) columns when the response carries total_flat_cost.
   JSON export summary gains total_flat_cost and total_cost for teams.

Networking:
- ptuReservationListCall / InfoCall / CreateCall / CloseCall in
  networking.tsx.
- usePtuReservations + useCreatePtuReservation + useClosePtuReservation
  React Query hooks mirroring useBudgets.
- useIsPtuCostAttributionEnabled reads /config/list general_settings.

Nav:
- 'PTU Reservations' item next to Budgets under the admin group.
- migratedPages entry so deep-links land on the path-based route.

UI-side detection of flat cost is data-shape driven: exports check for
spendData.metadata.total_flat_cost != null rather than plumbing
entityType through every helper. Backend only populates that field for
team responses today, so the effect is identical without the extra
parameter across the daily-export chain.

Tests (166 passed):
- ptu_reservation_panel.test.tsx: 6 tests — flag-off state, loading,
  empty state, table render, create-modal open, non-admin denial.
- utils.test.ts: 2 new tests covering the flat-cost column shape and
  metadata inclusion for team exports.
- EntityUsage.test.tsx: mocked the new hook so the existing 25 tests
  keep passing without needing a QueryClientProvider wrapper.

No backend or hot-path changes. No CSV export changes for non-team
entities. No changes to budgets or LiteLLM_TeamTable.spend.
This commit is contained in:
Yucheng Zhu 2026-07-14 16:37:49 -07:00
parent 48d46cc354
commit 26c9a92981
17 changed files with 861 additions and 23 deletions

View file

@ -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 };
};

View file

@ -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<PtuReservationItem[]> => {
const { accessToken } = useAuthorized();
return useQuery<PtuReservationItem[]>({
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<unknown, Error, Record<string, any>>({
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<unknown, Error, { id: string; effective_to?: string }>({
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 });
},
});
};

View file

@ -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<React.SetStateAction<boolean>>;
reservation: PtuReservationItem | null;
}
const CloseReservationModal: React.FC<CloseReservationModalProps> = ({
isModalVisible,
setIsModalVisible,
reservation,
}) => {
const [form] = Form.useForm();
const closeReservation = useClosePtuReservation();
const handleCancel = () => {
setIsModalVisible(false);
form.resetFields();
};
const handleClose = async (formValues: Record<string, any>) => {
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 (
<Modal
title="Close PTU Reservation"
open={isModalVisible}
width={640}
footer={null}
onCancel={handleCancel}
destroyOnHidden
>
<p>
Closing sets <code>effective_to</code> so the reservation stops accruing daily flat cost. Historical rollup rows
already written are preserved for audit.
</p>
<Form
form={form}
onFinish={handleClose}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
style={{ marginTop: 16 }}
>
<Form.Item label="Effective to" name="effective_to" help="Optional; defaults to now (UTC)">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit" loading={closeReservation.isPending} danger>
Close reservation
</Button2>
</div>
</Form>
</Modal>
);
};
export default CloseReservationModal;

View file

@ -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<React.SetStateAction<boolean>>;
}
const PtuReservationModal: React.FC<PtuReservationModalProps> = ({ isModalVisible, setIsModalVisible }) => {
const [form] = Form.useForm();
const createReservation = useCreatePtuReservation();
const handleCancel = () => {
setIsModalVisible(false);
form.resetFields();
};
const handleCreate = async (formValues: Record<string, any>) => {
try {
const body: Record<string, any> = {
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 (
<Modal
title="Create PTU Reservation"
open={isModalVisible}
width={720}
footer={null}
onCancel={handleCancel}
destroyOnHidden
>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item
label="Team ID"
name="team_id"
rules={[{ required: true, message: "Team id is required" }]}
help="Team that owns the reservation"
>
<Input placeholder="e.g., team-x" />
</Form.Item>
<Form.Item
label="Model"
name="model"
rules={[{ required: true, message: "Model is required" }]}
help="Model this reservation covers (e.g. gpt-4)"
>
<Input placeholder="e.g., gpt-4" />
</Form.Item>
<Form.Item
label="PTU count"
name="ptu_count"
rules={[
{ required: true, message: "PTU count is required" },
{ type: "number", min: 1, message: "PTU count must be at least 1" },
]}
>
<InputNumber step={1} min={1} precision={0} style={{ width: 200 }} />
</Form.Item>
<Form.Item
label="Cost per PTU (USD/month)"
name="cost_per_ptu"
rules={[
{ required: true, message: "Cost per PTU is required" },
{ type: "number", min: 0, message: "Cost cannot be negative" },
]}
>
<InputNumber step={0.01} min={0} precision={2} style={{ width: 200 }} />
</Form.Item>
<Form.Item
label="Effective from"
name="effective_from"
rules={[{ required: true, message: "Effective from is required" }]}
help="Inclusive UTC start"
>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Effective to" name="effective_to" help="Optional; leave empty for still active">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit" loading={createReservation.isPending}>
Create
</Button2>
</div>
</Form>
</Modal>
);
};
export default PtuReservationModal;

View file

@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
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(<PtuReservationPanel accessToken="test-token" />);
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(<PtuReservationPanel accessToken="test-token" />);
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(<PtuReservationPanel accessToken="test-token" />);
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(<PtuReservationPanel accessToken="test-token" />);
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(<PtuReservationPanel accessToken="test-token" />);
// 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(<PtuReservationPanel accessToken="t" />);
await waitFor(() => {
expect(screen.getByText(/proxy-admin access/i)).toBeInTheDocument();
});
expect(screen.queryByText("+ Create Reservation")).not.toBeInTheDocument();
});
});

View file

@ -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<PtuReservationPanelProps> = ({ accessToken }) => {
const [isCreateVisible, setIsCreateVisible] = useState(false);
const [isCloseVisible, setIsCloseVisible] = useState(false);
const [selected, setSelected] = useState<PtuReservationItem | null>(null);
const { userRole } = useAuthorized();
const canModify = isProxyAdminRole(userRole ?? "");
const { enabled: featureEnabled, isLoading: flagLoading } = useIsPtuCostAttributionEnabled();
const { data: reservations = [], isLoading } = usePtuReservations({}, { enabled: featureEnabled });
if (flagLoading) {
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<Card>
<Text>Loading&hellip;</Text>
</Card>
</div>
);
}
if (!featureEnabled) {
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<Card>
<Title level={4}>PTU Reservations</Title>
<Text style={{ marginTop: 8, display: "block" }}>
PTU cost attribution is disabled. Set <code>enable_ptu_cost_attribution: true</code> in{" "}
<code>general_settings</code> to use this feature.
</Text>
</Card>
</div>
);
}
if (!canModify) {
return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
<Card>
<Title level={4}>PTU Reservations</Title>
<Text style={{ marginTop: 8, display: "block" }}>You need proxy-admin access to view PTU reservations.</Text>
</Card>
</div>
);
}
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 (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{accessToken && (
<Button type="primary" size="small" style={{ marginBottom: 8 }} onClick={() => setIsCreateVisible(true)}>
+ Create Reservation
</Button>
)}
<Card>
<Title level={4}>PTU Reservations</Title>
<Text>
Register a flat prepaid cost for a (team, model) window. The daily rollup writes prorated flat cost to each
day&apos;s team spend row.
</Text>
{renderTable(isLoading, sortedReservations, now, handleCloseClick)}
</Card>
<PtuReservationModal isModalVisible={isCreateVisible} setIsModalVisible={setIsCreateVisible} />
<CloseReservationModal
isModalVisible={isCloseVisible}
setIsModalVisible={setIsCloseVisible}
reservation={selected}
/>
</div>
);
};
function renderTable(
isLoading: boolean,
rows: PtuReservationItem[],
now: Date,
handleCloseClick: (r: PtuReservationItem) => void,
) {
if (isLoading) {
return <Text style={{ marginTop: 16, display: "block" }}>Loading reservations&hellip;</Text>;
}
if (rows.length === 0) {
return (
<Text style={{ marginTop: 16, display: "block" }}>
No reservations yet. Click &quot;Create Reservation&quot; to add one.
</Text>
);
}
return (
<table className="w-full mt-4" style={{ borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={cellHead}>Team</th>
<th style={cellHead}>Model</th>
<th style={cellHead}>PTU count</th>
<th style={cellHead}>Cost / PTU</th>
<th style={cellHead}>Monthly total</th>
<th style={cellHead}>Effective from</th>
<th style={cellHead}>Effective to</th>
<th style={cellHead}>Status</th>
<th style={cellHead}>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const status = reservationStatus(row, now);
return (
<tr key={row.id}>
<td style={cellBody}>{row.team_id}</td>
<td style={cellBody}>{row.model}</td>
<td style={cellBody}>{row.ptu_count ?? "—"}</td>
<td style={cellBody}>
<MoneyCell value={row.cost_per_ptu} decimals={2} showZero emptyText="—" />
</td>
<td style={cellBody}>
<MoneyCell value={monthlyTotal(row)} decimals={2} showZero emptyText="—" />
</td>
<td style={cellBody}>{new Date(row.effective_from).toISOString()}</td>
<td style={cellBody}>{row.effective_to ? new Date(row.effective_to).toISOString() : "—"}</td>
<td style={cellBody}>
<Tag color={statusColor(status)}>{status}</Tag>
</td>
<td style={cellBody}>{renderActionCell(status, row, handleCloseClick)}</td>
</tr>
);
})}
</tbody>
</table>
);
}
function renderActionCell(
status: ReservationStatus,
row: PtuReservationItem,
handleCloseClick: (r: PtuReservationItem) => void,
) {
if (status === "ended") {
return <span>—</span>;
}
return (
<TableIconActionButton
variant="Delete"
tooltipText="Close reservation"
onClick={() => 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;

View file

@ -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 <PtuReservationPanel accessToken={accessToken} />;
}

View file

@ -49,6 +49,10 @@ vi.mock("@/components/common_components/team_multi_select", () => ({
default: () => <div>Team Multi Select</div>,
}));
vi.mock("@/app/(dashboard)/hooks/ptuReservations/useIsPtuCostAttributionEnabled", () => ({
useIsPtuCostAttributionEnabled: () => ({ enabled: false, isLoading: false }),
}));
// Mock useTeams hook
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
default: vi.fn(() => ({

View file

@ -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<EntityUsageProps> = ({ accessToken, entityType, enti
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const [topAgentsLimit, setTopAgentsLimit] = useState<number>(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<EntityUsageProps> = ({ accessToken, entityType, enti
<Col numColSpan={2}>
<Card>
<Title>{capitalizedEntityLabel} Spend Overview</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Grid numItems={showFlatCost ? 7 : 5} className="gap-4 mt-4">
<Card>
<Title>Total Spend</Title>
<Text className="text-2xl font-bold mt-2">
${formatNumberWithCommas(spendData.metadata.total_spend, 2)}
</Text>
</Card>
{showFlatCost && (
<>
<Card>
<Title>Flat Cost</Title>
<Text className="text-2xl font-bold mt-2">
${formatNumberWithCommas(spendData.metadata.total_flat_cost ?? 0, 2)}
</Text>
</Card>
<Card>
<Title>Total Cost</Title>
<Text className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
spendData.metadata.total_spend + (spendData.metadata.total_flat_cost ?? 0),
2,
)}
</Text>
</Card>
</>
)}
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">

View file

@ -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;

View file

@ -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", () => {

View file

@ -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<string, string> = {},
): 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<string, any> = {
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<string, any> = {
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,

View file

@ -1,5 +1,6 @@
export interface SpendMetrics {
spend: number;
flat_cost?: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;

View file

@ -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: <Wallet {...ICON} />, roles: all_admin_roles },
{
key: "ptu-reservations",
page: "ptu-reservations",
label: "PTU Reservations",
icon: <Coins {...ICON} />,
roles: all_admin_roles,
},
],
},
{

View file

@ -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<string, any>) => {
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<string, any> = { 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

View file

@ -28,6 +28,7 @@ export const pageDescriptions: Record<string, string> = {
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",

View file

@ -19,6 +19,7 @@ export const MIGRATED_PAGES: Record<string, string> = {
chat: "chat",
"access-groups": "access-groups",
budgets: "budgets",
"ptu-reservations": "ptu-reservations",
workflows: "workflows",
"guardrails-monitor": "guardrails-monitor",
"mcp-servers": "mcp-servers",