mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
48d46cc354
commit
26c9a92981
17 changed files with 861 additions and 23 deletions
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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…</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'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…</Text>;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Text style={{ marginTop: 16, display: "block" }}>
|
||||
No reservations yet. Click "Create Reservation" 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;
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface SpendMetrics {
|
||||
spend: number;
|
||||
flat_cost?: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue