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 4d44791d1a9..2171ce847af 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 @@ -1,5 +1,6 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { BarChart, DonutChart } from "@/components/shared/charts"; +import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; import { MoneyCell } from "@/components/shared/table_cells"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -23,8 +24,8 @@ import { Text, Title, } from "@tremor/react"; -import { ExportOutlined, LoadingOutlined } from "@ant-design/icons"; -import { Alert, Button } from "antd"; +import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import { Alert, Button, Tooltip } from "antd"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; @@ -76,6 +77,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; @@ -115,6 +117,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); const [topAgentsLimit, setTopAgentsLimit] = useState(5); + const [showCostBreakdown, setShowCostBreakdown] = useState(false); const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); @@ -408,42 +411,39 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti }; const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1); + const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata); + + const chev = "text-gray-400 text-xs"; + const expandIcon = showCostBreakdown ? : ; + const infoIcon = ; + + const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => ( + setShowCostBreakdown(!showCostBreakdown) : undefined} + > +
+ {title} + {tooltip ? {infoIcon} : null} + {expandable ? expandIcon : null} +
+ {value} +
+ ); + + const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : []; + const summaryTiles = [...buildSummaryTiles(spendData.metadata, showFlatCost), ...breakdownTiles]; const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"; const costPanel = ( - {/* Total Spend Card */} {capitalizedEntityLabel} Spend Overview - - Total Spend - - ${formatNumberWithCommas(spendData.metadata.total_spend, 2)} - - - - Total Requests - {spendData.metadata.total_api_requests.toLocaleString()} - - - Successful Requests - - {spendData.metadata.total_successful_requests.toLocaleString()} - - - - Failed Requests - - {spendData.metadata.total_failed_requests.toLocaleString()} - - - - Total Tokens - {spendData.metadata.total_tokens.toLocaleString()} - + {summaryTiles.map(renderSummaryTile)} @@ -456,21 +456,40 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti new Date(a.date).getTime() - new Date(b.date).getTime())} + data={[...spendData.results] + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + .map((row) => ({ + ...row, + "Request cost": row.metrics.spend ?? 0, + "Flat cost": row.metrics.flat_cost ?? 0, + }))} index="date" - categories={["metrics.spend"]} - colors={["cyan"]} + categories={showFlatCost ? ["Request cost", "Flat cost"] : ["metrics.spend"]} + colors={showFlatCost ? ["cyan", "violet"] : ["cyan"]} + stack={showFlatCost} valueFormatter={valueFormatterSpend} yAxisWidth={100} - showLegend={false} + showLegend={showFlatCost} customTooltip={({ payload, active }) => { if (!active || !payload?.[0]) return null; const data = payload[0].payload; const entityCount = Object.keys(data.breakdown.entities || {}).length; + const requestSpend = data.metrics.spend ?? 0; + const flatCost = data.metrics.flat_cost ?? 0; return (

{data.date}

-

Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}

+ {showFlatCost ? ( + <> +

Request cost: ${formatNumberWithCommas(requestSpend, 2)}

+

Flat cost: ${formatNumberWithCommas(flatCost, 2)}

+

+ Total cost: ${formatNumberWithCommas(requestSpend + flatCost, 2)} +

+ + ) : ( +

Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}

+ )}

Total Requests: {data.metrics.api_requests}

Successful: {data.metrics.successful_requests}

Failed: {data.metrics.failed_requests}

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageSummary.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageSummary.test.ts new file mode 100644 index 00000000000..403f9473391 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageSummary.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost } from "./entityUsageSummary"; + +const metadata = { + total_spend: 100, + total_flat_cost: 40, + total_api_requests: 12, + total_successful_requests: 10, + total_failed_requests: 2, + total_tokens: 3456, +}; + +describe("hasFlatCost", () => { + it("is false when there is no flat cost to report", () => { + expect(hasFlatCost({ ...metadata, total_flat_cost: 0 })).toBe(false); + const { total_flat_cost, ...noFlat } = metadata; + expect(hasFlatCost(noFlat)).toBe(false); + }); + + it("is true once a flat cost has accrued", () => { + expect(hasFlatCost(metadata)).toBe(true); + }); +}); + +describe("buildSummaryTiles", () => { + it("keeps the row at five tiles either way so adding flat cost never narrows the cards", () => { + expect(buildSummaryTiles(metadata, false)).toHaveLength(5); + expect(buildSummaryTiles(metadata, true)).toHaveLength(5); + }); + + it("shows request-only spend under the original title when there is no flat cost", () => { + const [first] = buildSummaryTiles(metadata, false); + expect(first.title).toBe("Total Spend"); + expect(first.value).toBe("$100.00"); + expect(first.expandable).toBeUndefined(); + }); + + it("rolls flat cost into a single expandable Total Cost tile", () => { + const [first] = buildSummaryTiles(metadata, true); + expect(first.title).toBe("Total Cost"); + expect(first.value).toBe("$140.00"); + expect(first.expandable).toBe(true); + expect(first.tooltip).toBeTruthy(); + }); + + it("never renders the breakdown titles in the top row", () => { + const titles = buildSummaryTiles(metadata, true).map((t) => t.title); + expect(titles).not.toContain("Flat Cost"); + expect(titles).not.toContain("Request Cost"); + }); + + it("treats a missing flat cost as zero", () => { + const { total_flat_cost, ...noFlat } = metadata; + expect(buildSummaryTiles(noFlat, true)[0].value).toBe("$100.00"); + }); +}); + +describe("buildCostBreakdownTiles", () => { + it("splits the total into request cost and flat cost", () => { + const byTitle = Object.fromEntries(buildCostBreakdownTiles(metadata).map((t) => [t.title, t.value])); + expect(byTitle["Request Cost"]).toBe("$100.00"); + expect(byTitle["Flat Cost"]).toBe("$40.00"); + }); + + it("adds up to the Total Cost tile so the expanded view reconciles", () => { + const parse = (v: string) => Number(v.replace(/[$,]/g, "")); + const parts = buildCostBreakdownTiles(metadata).map((t) => parse(t.value)); + expect(parts[0] + parts[1]).toBe(parse(buildSummaryTiles(metadata, true)[0].value)); + }); + + it("explains each part, including that flat cost is outside budgets", () => { + const byTitle = Object.fromEntries(buildCostBreakdownTiles(metadata).map((t) => [t.title, t.tooltip])); + expect(byTitle["Request Cost"]).toBeTruthy(); + expect(byTitle["Flat Cost"]).toContain("budget"); + }); + + it("treats a missing flat cost as zero", () => { + const { total_flat_cost, ...noFlat } = metadata; + const byTitle = Object.fromEntries(buildCostBreakdownTiles(noFlat).map((t) => [t.title, t.value])); + expect(byTitle["Flat Cost"]).toBe("$0.00"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageSummary.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageSummary.ts new file mode 100644 index 00000000000..093cd9c40af --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityUsageSummary.ts @@ -0,0 +1,66 @@ +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +export interface SummaryTile { + title: string; + value: string; + className?: string; + tooltip?: string; + expandable?: boolean; +} + +interface SpendSummaryMetadata { + total_spend: number; + total_flat_cost?: number; + total_api_requests: number; + total_successful_requests: number; + total_failed_requests: number; + total_tokens: number; +} + +export const TOTAL_COST_TOOLTIP = + "Request cost plus flat cost for reserved capacity. Select this tile to see the breakdown."; + +export const REQUEST_COST_TOOLTIP = + "Usage-based cost of the requests this entity sent during the selected period, priced per token."; + +export const FLAT_COST_TOOLTIP = + "Reserved provisioned throughput, billed per hour whether or not requests are sent. Reported here only; it does not count toward team, key, user, or organization budgets."; + +export const hasFlatCost = (metadata: SpendSummaryMetadata): boolean => (metadata.total_flat_cost ?? 0) > 0; + +export const buildSummaryTiles = (metadata: SpendSummaryMetadata, showFlatCost: boolean): SummaryTile[] => { + const flatCost = metadata.total_flat_cost ?? 0; + return [ + showFlatCost + ? { + title: "Total Cost", + value: `$${formatNumberWithCommas(metadata.total_spend + flatCost, 2)}`, + tooltip: TOTAL_COST_TOOLTIP, + expandable: true, + } + : { title: "Total Spend", value: `$${formatNumberWithCommas(metadata.total_spend, 2)}` }, + { title: "Total Requests", value: metadata.total_api_requests.toLocaleString() }, + { + title: "Successful Requests", + value: metadata.total_successful_requests.toLocaleString(), + className: "text-green-600", + }, + { title: "Failed Requests", value: metadata.total_failed_requests.toLocaleString(), className: "text-red-600" }, + { title: "Total Tokens", value: metadata.total_tokens.toLocaleString() }, + ]; +}; + +export const buildCostBreakdownTiles = (metadata: SpendSummaryMetadata): SummaryTile[] => [ + { + title: "Request Cost", + value: `$${formatNumberWithCommas(metadata.total_spend, 2)}`, + className: "text-cyan-600", + tooltip: REQUEST_COST_TOOLTIP, + }, + { + title: "Flat Cost", + value: `$${formatNumberWithCommas(metadata.total_flat_cost ?? 0, 2)}`, + className: "text-violet-600", + tooltip: FLAT_COST_TOOLTIP, + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts new file mode 100644 index 00000000000..b1d467074f6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { sumMetadata } from "./usePaginatedDailyActivity"; + +describe("sumMetadata", () => { + it("sums flat cost across pages instead of keeping the first page's value", () => { + // A team whose activity spans more than one page accrues flat cost on each of them. + // Keeping page 1's value under-reports the Flat Cost and Total Cost tiles. + const merged = sumMetadata({ total_spend: 1, total_flat_cost: 174.5 }, { total_spend: 2, total_flat_cost: 777 }); + + expect(merged.total_flat_cost).toBe(951.5); + expect(merged.total_spend).toBe(3); + }); + + it("treats a page missing the field as zero rather than dropping the running total", () => { + expect(sumMetadata({ total_flat_cost: 480 }, {}).total_flat_cost).toBe(480); + expect(sumMetadata({}, { total_flat_cost: 480 }).total_flat_cost).toBe(480); + }); + + it("carries non-summable keys through from the first page", () => { + const merged = sumMetadata( + { page: 1, total_pages: 3, total_spend: 1 }, + { page: 2, total_pages: 3, total_spend: 2 }, + ); + + expect(merged.page).toBe(1); + expect(merged.total_pages).toBe(3); + }); + + it("sums every total_* metric the daily activity metadata exposes", () => { + // Guards the class of bug rather than one field: a new backend total that nobody adds + // to SUMMABLE_METADATA_KEYS freezes at page 1, and spend still looks right so it reads + // as trustworthy. + const page = { + total_spend: 1, + total_prompt_tokens: 1, + total_completion_tokens: 1, + total_tokens: 1, + total_api_requests: 1, + total_successful_requests: 1, + total_failed_requests: 1, + total_cache_read_input_tokens: 1, + total_cache_creation_input_tokens: 1, + total_flat_cost: 1, + }; + const merged = sumMetadata(page, page); + + for (const key of Object.keys(page)) { + expect(merged[key], `${key} must be summed across pages`).toBe(2); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts index 86b26bda539..a8bfee5be2f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts @@ -23,6 +23,7 @@ const SUMMABLE_METADATA_KEYS = [ "total_failed_requests", "total_cache_read_input_tokens", "total_cache_creation_input_tokens", + "total_flat_cost", ] as const; interface DailyActivityResponse { @@ -68,7 +69,12 @@ const EMPTY_DATA: DailyActivityResponse = { }, }; -function sumMetadata(a: Record, b: Record): Record { +/** + * Combine two pages of metadata. Only keys in SUMMABLE_METADATA_KEYS are added; anything + * else keeps the first page's value, so a total the backend adds later is silently frozen + * at page 1 until it is listed above. Exported so that contract can be tested directly. + */ +export function sumMetadata(a: Record, b: Record): Record { const result = { ...a }; for (const key of SUMMABLE_METADATA_KEYS) { result[key] = (a[key] || 0) + (b[key] || 0); 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 2f551176091..08ca298c1f1 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1861,6 +1861,87 @@ 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 summary when total_flat_cost is present", () => { + const spendWithFlat: EntitySpendData = { + ...mockSpendData, + metadata: { ...mockSpendData.metadata, total_flat_cost: 6.45 }, + }; + const result = generateMetadata("team", mockDateRange, [], "daily", spendWithFlat); + 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 when total_flat_cost is zero", () => { + const zeroFlat = { ...mockSpendData, metadata: { ...mockSpendData.metadata, total_flat_cost: 0 } }; + const result = generateMetadata("team", mockDateRange, [], "daily", zeroFlat); + 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 zero", () => { + const spendWithoutFlat: EntitySpendData = { + ...dayWithFlat, + metadata: { + total_spend: 10, + total_api_requests: 50, + total_successful_requests: 50, + total_failed_requests: 0, + total_tokens: 500, + total_flat_cost: 0, + }, + }; + 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 53d100040d7..9adcb50206d 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -110,31 +110,42 @@ export const getEntityBreakdown = ( return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend); }; +// total_flat_cost defaults to 0 on every entity response, so only a non-zero value +// means a PTU-configured team actually accrued flat cost worth exporting. +const hasFlatCost = (spendData: EntitySpendData): boolean => (spendData.metadata.total_flat_cost ?? 0) > 0; + 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, data.metadata); - 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); }); }); @@ -339,23 +350,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: ExportMetadata["summary"] = { 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 (hasFlatCost(spendData)) { + 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 b10fc79be15..420977f8c31 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/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index 554fe86bf66..9bc78783a57 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Form, Switch, Select, Tooltip } from "antd"; +import { Form, Switch, Select, Tooltip, DatePicker } from "antd"; import { Text, Accordion, AccordionHeader, AccordionBody, TextInput } from "@tremor/react"; import { Row, Col, Typography } from "antd"; import TextArea from "antd/es/input/TextArea"; @@ -9,6 +9,17 @@ import CacheControlSettings from "./cache_control_settings"; import VectorStoreSelector from "../vector_store_management/VectorStoreSelector"; import { Tag } from "../tag_management/types"; import { formItemValidateJSON } from "../../utils/textUtils"; +import { + PTU_COUNT_FIELD, + PTU_RATE_FIELD, + PTU_START_FIELD, + ptuCountRules, + ptuPairRule, + ptuRateRules, + ptuStartRequiredRule, + ptuWindowOrderRule, + PTU_END_FIELD, +} from "../../utils/ptuValidation"; const { Link } = Typography; interface AdvancedSettingsProps { @@ -182,6 +193,50 @@ const AdvancedSettings: React.FC = ({ /> + + + + + + + + + + + + + + + + {customPricing && (
diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index 908a4c498dc..af5fe0931c8 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -1,6 +1,7 @@ import NotificationManager from "../molecules/notifications_manager"; import { Model, modelCreateCall } from "../networking"; import { provider_map } from "../provider_info_helpers"; +import { ptuPickerToUtcIso } from "../../utils/ptuDatetime"; export const prepareModelAddRequest = async (formValues: Record, accessToken: string, form: any) => { try { @@ -163,6 +164,23 @@ export const prepareModelAddRequest = async (formValues: Record, ac continue; } + // Handle the PTU flat-cost fields (attributed to the team via model_info) + else if (key === "ptu_count" || key === "cost_per_ptu_per_hour") { + if (value !== undefined && value !== null && value !== "") { + modelInfoObj[key] = Number(value); + } + continue; + } + + // Handle the PTU effective window (DatePicker dayjs value -> ISO 8601 UTC string) + else if (key === "ptu_effective_from" || key === "ptu_effective_to") { + const iso = ptuPickerToUtcIso(value); + if (iso !== null) { + modelInfoObj[key] = iso; + } + continue; + } + // Check if key is any of the specified API related keys else { // Add key-value pair to litellm_params dictionary diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index c3327911993..bd8da531a8f 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -17,7 +17,19 @@ import { Title, Button as TremorButton, } from "@tremor/react"; -import { Button, Form, Input, Modal, Select, Tooltip } from "antd"; +import { Button, DatePicker, Form, Input, Modal, Select, Tooltip } from "antd"; +import { formatPtuUtcDisplay, ptuPickerToUtcIso, utcIsoToPickerValue } from "../utils/ptuDatetime"; +import { + PTU_COUNT_FIELD, + PTU_RATE_FIELD, + ptuCountRules, + ptuPairRule, + ptuRateRules, + ptuStartRequiredRule, + ptuWindowOrderRule, + PTU_END_FIELD, + PTU_START_FIELD, +} from "../utils/ptuValidation"; import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -67,6 +79,62 @@ interface ModelInfoViewProps { modelAccessGroups: string[] | null; } +interface PtuEditField { + name: string; + label: string; + input: "number" | "datetime"; + placeholder?: string; + isCount?: boolean; + isRate?: boolean; + isStart?: boolean; + pairedWith?: string; + windowPeer?: string; + bound?: "start" | "end"; +} + +const PTU_EDIT_FIELDS: PtuEditField[] = [ + { + name: PTU_COUNT_FIELD, + label: "PTU Count", + input: "number", + placeholder: "e.g. 15", + isCount: true, + pairedWith: PTU_RATE_FIELD, + }, + { + name: PTU_RATE_FIELD, + label: "Cost per PTU / Hour (USD)", + input: "number", + placeholder: "e.g. 2.00", + isRate: true, + pairedWith: PTU_COUNT_FIELD, + }, + { + name: PTU_START_FIELD, + label: "PTU Effective From (UTC)", + input: "datetime", + isStart: true, + windowPeer: PTU_END_FIELD, + bound: "start", + }, + { + name: PTU_END_FIELD, + label: "PTU Effective To (UTC)", + input: "datetime", + windowPeer: PTU_START_FIELD, + bound: "end", + }, +]; + +const ptuFieldDependencies = ({ isStart, pairedWith, windowPeer }: PtuEditField): string[] | undefined => { + const deps = [ + ...(isStart ? [PTU_COUNT_FIELD] : []), + ...(pairedWith ? [pairedWith] : []), + ...(windowPeer ? [windowPeer] : []), + ]; + return deps.length ? deps : undefined; +}; + interface ComplexityRouterTierConfig { tiers?: { SIMPLE?: unknown; @@ -427,6 +495,15 @@ export default function ModelInfoView({ health_check_model: values.health_check_model, }; } + const ptuNumber = (val: string | number | null | undefined): number | null => + val !== undefined && val !== null && val !== "" ? Number(val) : null; + updatedModelInfo = { + ...updatedModelInfo, + ptu_count: ptuNumber(values.ptu_count), + cost_per_ptu_per_hour: ptuNumber(values.cost_per_ptu_per_hour), + ptu_effective_from: ptuPickerToUtcIso(values.ptu_effective_from), + ptu_effective_to: ptuPickerToUtcIso(values.ptu_effective_to), + }; } catch (e) { NotificationsManager.fromBackend("Invalid JSON in Model Info"); return; @@ -769,6 +846,10 @@ export default function ModelInfoView({ output_cost: localModelData.litellm_params?.output_cost_per_token ? localModelData.litellm_params.output_cost_per_token * 1_000_000 : localModelData.model_info?.output_cost_per_token * 1_000_000 || null, + ptu_count: localModelData.model_info?.ptu_count ?? null, + cost_per_ptu_per_hour: localModelData.model_info?.cost_per_ptu_per_hour ?? null, + ptu_effective_from: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_from), + ptu_effective_to: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_to), cache_read_cost: localModelData.litellm_params?.cache_read_input_token_cost !== undefined && localModelData.litellm_params?.cache_read_input_token_cost !== null @@ -872,6 +953,46 @@ export default function ModelInfoView({ )}
+ {PTU_EDIT_FIELDS.map((ptuField) => { + const { name, label, input, placeholder, isCount, isRate, isStart, pairedWith } = ptuField; + const { windowPeer, bound } = ptuField; + return ( +
+ {label} + {isEditing ? ( + + {input === "number" ? ( + + ) : ( + + )} + + ) : ( +
+ {(input === "datetime" + ? formatPtuUtcDisplay(localModelData?.model_info?.[name]) + : localModelData?.model_info?.[name]) ?? "Not Set"} +
+ )} +
+ ); + })} +
Cache Read Cost (per 1M tokens) {isEditing ? ( diff --git a/ui/litellm-dashboard/src/utils/ptuDatetime.test.ts b/ui/litellm-dashboard/src/utils/ptuDatetime.test.ts new file mode 100644 index 00000000000..baaa246d6de --- /dev/null +++ b/ui/litellm-dashboard/src/utils/ptuDatetime.test.ts @@ -0,0 +1,96 @@ +import dayjs from "dayjs"; +import { describe, expect, it } from "vitest"; +import { formatPtuUtcDisplay, ptuPickerToUtcIso, utcIsoToPickerValue } from "./ptuDatetime"; + +describe("ptuDatetime", () => { + it("stores the picked wall-clock time as UTC instead of shifting across zones", () => { + const picked = dayjs("2024-03-10T23:00:00"); + expect(ptuPickerToUtcIso(picked)).toBe("2024-03-10T23:00:00.000Z"); + }); + + it("returns null for empty picker values", () => { + expect(ptuPickerToUtcIso(null)).toBeNull(); + expect(ptuPickerToUtcIso(undefined)).toBeNull(); + }); + + it("round-trips a UTC ISO string back to the same wall-clock in the picker", () => { + const value = utcIsoToPickerValue("2024-03-10T23:00:00.000Z"); + expect(value).not.toBeNull(); + expect(value!.format("YYYY-MM-DDTHH:mm:ss")).toBe("2024-03-10T23:00:00"); + expect(ptuPickerToUtcIso(value)).toBe("2024-03-10T23:00:00.000Z"); + }); + + it("returns null for empty ISO strings", () => { + expect(utcIsoToPickerValue(null)).toBeNull(); + expect(utcIsoToPickerValue(undefined)).toBeNull(); + expect(utcIsoToPickerValue("")).toBeNull(); + }); +}); + +describe("formatPtuUtcDisplay", () => { + it("renders the two stored serialisations identically", () => { + // the backend writes +00:00, a just-saved form holds the picker's .000Z + expect(formatPtuUtcDisplay("2026-08-01T23:00:00+00:00")).toBe("2026-08-01 23:00:00 UTC"); + expect(formatPtuUtcDisplay("2026-08-01T23:00:00.000Z")).toBe("2026-08-01 23:00:00 UTC"); + }); + + it("shows the UTC instant regardless of the offset it was written with", () => { + expect(formatPtuUtcDisplay("2026-08-01T16:00:00-07:00")).toBe("2026-08-01 23:00:00 UTC"); + }); + + it("returns null for empty values so the caller can fall back to Not Set", () => { + expect(formatPtuUtcDisplay(null)).toBeNull(); + expect(formatPtuUtcDisplay(undefined)).toBeNull(); + expect(formatPtuUtcDisplay("")).toBeNull(); + }); + + it("passes an unparseable value through rather than hiding it", () => { + expect(formatPtuUtcDisplay("not-a-date")).toBe("not-a-date"); + }); +}); + +describe("DST spring-forward gap", () => { + // 2027-03-14 02:30 does not exist in America/Los_Angeles: the clock jumps 02:00 -> 03:00. + const GAP_ISO = "2027-03-14T02:30:00+00:00"; + + it("keeps the stored wall clock when it falls in the local DST gap", () => { + const picked = utcIsoToPickerValue(GAP_ISO); + expect(picked).not.toBeNull(); + expect(picked!.format("YYYY-MM-DDTHH:mm:ss")).toBe("2027-03-14T02:30:00"); + }); + + it("round-trips the gap instant back out unchanged, so a save cannot shift it", () => { + expect(ptuPickerToUtcIso(utcIsoToPickerValue(GAP_ISO))).toBe("2027-03-14T02:30:00.000Z"); + }); + + it("round-trips a fall-back ambiguous instant unchanged too", () => { + // 2027-11-07 01:30 occurs twice in America/Los_Angeles + const AMBIGUOUS = "2027-11-07T01:30:00+00:00"; + expect(ptuPickerToUtcIso(utcIsoToPickerValue(AMBIGUOUS))).toBe("2027-11-07T01:30:00.000Z"); + }); + + it("returns null for an unparseable stored value instead of an Invalid Date picker", () => { + expect(utcIsoToPickerValue("not-a-date")).toBeNull(); + }); +}); + +describe("sub-second precision", () => { + // The backend persists value.isoformat() verbatim, so a window set out of band (curl, + // which is this repo's documented setup path) can carry microseconds. Every save re-sends + // both window fields, so a lossy round-trip would rewrite an untouched billing window. + it("preserves a sub-second stored instant through a save", () => { + const stored = "2026-08-01T23:00:00.500000+00:00"; + expect(ptuPickerToUtcIso(utcIsoToPickerValue(stored))).toBe("2026-08-01T23:00:00.500Z"); + }); + + it("keeps the sub-second component visible on the picker value", () => { + expect(utcIsoToPickerValue("2026-08-01T23:00:00.500000+00:00")!.millisecond()).toBe(500); + }); + + it("still reinterprets a freshly picked local-mode value as UTC", () => { + // a value the operator picks has no sub-second part and must not be zone-converted + const localPick = dayjs("2026-08-01T23:00:00"); + expect(localPick.isUTC()).toBe(false); + expect(ptuPickerToUtcIso(localPick)).toBe("2026-08-01T23:00:00.000Z"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/ptuDatetime.ts b/ui/litellm-dashboard/src/utils/ptuDatetime.ts new file mode 100644 index 00000000000..33388a0e94c --- /dev/null +++ b/ui/litellm-dashboard/src/utils/ptuDatetime.ts @@ -0,0 +1,61 @@ +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + +const WALL_CLOCK_FORMAT = "YYYY-MM-DDTHH:mm:ss"; + +/** + * Read the wall clock the picker is showing and stamp it as UTC. + * + * The picker value stays in UTC mode end to end (see `utcIsoToPickerValue`), so `.format()` + * returns the digits the operator sees and no zone conversion happens on the way out. + */ +export const ptuPickerToUtcIso = (value: Dayjs | null | undefined): string | null => { + if (!value || typeof value.format !== "function") { + return null; + } + // A value that came from storage is already in UTC mode and holds the exact stored + // instant. Every save re-sends both window fields, so routing it back through a + // second-granularity wall clock would silently drop any sub-second component of a + // window that was set out of band, turning an unrelated edit into a quiet rewrite. + if (typeof value.isUTC === "function" && value.isUTC()) { + return value.toISOString(); + } + // A freshly picked value is in the browser's zone; its wall clock is what the operator + // chose against a UTC-labelled field, so it is reinterpreted rather than converted. + return dayjs.utc(value.format(WALL_CLOCK_FORMAT)).toISOString(); +}; + +/** + * Hand the picker a UTC-mode Dayjs so it displays the stored wall clock verbatim. + * + * Re-parsing the wall clock in the browser's zone looks equivalent but is not: a clock reading + * that does not exist locally, the hour a DST spring-forward skips, gets advanced by the engine. + * `dayjs("2027-03-14T02:30:00")` is 03:30 in America/Los_Angeles, and because the save path + * re-stamps whatever the picker holds as UTC, that shift would be written back to the stored + * window rather than cancelled out. + */ +export const utcIsoToPickerValue = (iso: string | null | undefined): Dayjs | null => { + if (!iso) { + return null; + } + const parsed = dayjs.utc(iso); + return parsed.isValid() ? parsed : null; +}; + +const DISPLAY_FORMAT = "YYYY-MM-DD HH:mm:ss"; + +/** + * Render a stored PTU timestamp for the read view. The backend serialises as `+00:00` while a + * just-saved form holds the `.000Z` the picker produced, so the same instant would otherwise be + * shown two different ways depending on whether the page has been reloaded since the edit. An + * unparseable value is passed through rather than hidden. + */ +export const formatPtuUtcDisplay = (iso: string | null | undefined): string | null => { + if (!iso) { + return null; + } + const parsed = dayjs.utc(iso); + return parsed.isValid() ? `${parsed.format(DISPLAY_FORMAT)} UTC` : String(iso); +}; diff --git a/ui/litellm-dashboard/src/utils/ptuValidation.test.ts b/ui/litellm-dashboard/src/utils/ptuValidation.test.ts new file mode 100644 index 00000000000..1338c2a39ae --- /dev/null +++ b/ui/litellm-dashboard/src/utils/ptuValidation.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + PTU_COUNT_FIELD, + PTU_RATE_FIELD, + ptuCountRules, + ptuPairRule, + ptuRateRules, + ptuStartRequiredRule, + ptuWindowOrderRule, + PTU_END_FIELD, + PTU_START_FIELD, + MAX_PTU_COUNT, + MAX_COST_PER_PTU_PER_HOUR, +} from "./ptuValidation"; + +const validate = (value: unknown) => ptuCountRules[0].validator(null, value); + +describe("ptuCountRules", () => { + it("accepts positive whole numbers and empty values", async () => { + await expect(validate(5)).resolves.toBeUndefined(); + await expect(validate("15")).resolves.toBeUndefined(); + await expect(validate("")).resolves.toBeUndefined(); + await expect(validate(null)).resolves.toBeUndefined(); + await expect(validate(undefined)).resolves.toBeUndefined(); + }); + + it("rejects fractional values that the backend integer contract would refuse", async () => { + await expect(validate(2.5)).rejects.toThrow("whole number between 1 and"); + await expect(validate("1.25")).rejects.toThrow("whole number between 1 and"); + }); + + it("rejects zero and negatives, which the backend rejects as a non-positive ptu_count", async () => { + await expect(validate(0)).rejects.toThrow("whole number between 1 and"); + await expect(validate(-1)).rejects.toThrow("whole number between 1 and"); + await expect(validate("-3")).rejects.toThrow("whole number between 1 and"); + }); + + it("rejects a value that is not a number at all", async () => { + await expect(validate("abc")).rejects.toThrow("whole number between 1 and"); + }); +}); + +describe("ptuPairRule", () => { + const rule = (sibling: unknown) => ptuPairRule(PTU_RATE_FIELD)({ getFieldValue: () => sibling }); + const check = (value: unknown, sibling: unknown) => rule(sibling).validator(null, value); + + it("accepts both set and both cleared, the only shapes the backend stores", async () => { + await expect(check(10, 2.0)).resolves.toBeUndefined(); + await expect(check("", "")).resolves.toBeUndefined(); + await expect(check(null, undefined)).resolves.toBeUndefined(); + }); + + it("rejects a half-set pair, which the backend answers with a 400", async () => { + await expect(check(10, "")).rejects.toThrow("must be set together"); + await expect(check("", 2.0)).rejects.toThrow("must be set together"); + await expect(check(null, 2.0)).rejects.toThrow("must be set together"); + }); + + it("reads the sibling by the field name it was given", () => { + const seen: string[] = []; + ptuPairRule(PTU_COUNT_FIELD)({ + getFieldValue: (name: string) => { + seen.push(name); + return 1; + }, + }).validator(null, 1); + expect(seen).toEqual([PTU_COUNT_FIELD]); + }); +}); + +describe("ptuRateRules", () => { + const validate = (value: unknown) => ptuRateRules[0].validator(null, value); + + it("rejects a negative rate, which the backend answers with a 400", async () => { + await expect(validate(-1)).rejects.toThrow("must be between 0 and"); + }); + + it("rejects a negative rate typed as a string, which is what an input yields", async () => { + await expect(validate("-0.5")).rejects.toThrow("must be between 0 and"); + }); + + it("allows zero, which the backend accepts", async () => { + await expect(validate(0)).resolves.toBeUndefined(); + }); + + it("allows a fractional rate", async () => { + await expect(validate(2.5)).resolves.toBeUndefined(); + }); + + it("leaves an empty field to the pair rule", async () => { + await expect(validate("")).resolves.toBeUndefined(); + await expect(validate(null)).resolves.toBeUndefined(); + await expect(validate(undefined)).resolves.toBeUndefined(); + }); + + it("rejects a value that is not a number at all", async () => { + await expect(validate("abc")).rejects.toThrow("must be between 0 and"); + }); +}); + +describe("ptuStartRequiredRule", () => { + const rule = (count: unknown, start: unknown) => + ptuStartRequiredRule(PTU_COUNT_FIELD)({ getFieldValue: () => count }).validator(null, start); + + it("rejects PTU config with no effective start, which the backend answers with a 400", async () => { + await expect(rule(10, undefined)).rejects.toThrow("PTU Effective From is required when PTU Count is set"); + await expect(rule(10, "")).rejects.toThrow("PTU Effective From is required when PTU Count is set"); + }); + + it("allows a start once given", async () => { + await expect(rule(10, "2026-08-01T00:00:00Z")).resolves.toBeUndefined(); + }); + + it("leaves a deployment with no PTU config alone", async () => { + await expect(rule(undefined, undefined)).resolves.toBeUndefined(); + }); +}); + +describe("ptuWindowOrderRule", () => { + const form = (values: Record) => ({ getFieldValue: (name: string) => values[name] }); + const start = new Date("2026-08-01T00:00:00Z"); + const end = new Date("2026-09-01T00:00:00Z"); + + it("accepts an ordered window from either bound", async () => { + await expect( + ptuWindowOrderRule(PTU_END_FIELD, "start")(form({ [PTU_END_FIELD]: end })).validator(null, start), + ).resolves.toBeUndefined(); + await expect( + ptuWindowOrderRule(PTU_START_FIELD, "end")(form({ [PTU_START_FIELD]: start })).validator(null, end), + ).resolves.toBeUndefined(); + }); + + it("rejects an inverted window from either bound", async () => { + await expect( + ptuWindowOrderRule(PTU_END_FIELD, "start")(form({ [PTU_END_FIELD]: start })).validator(null, end), + ).rejects.toThrow("PTU Effective To must be after PTU Effective From"); + await expect( + ptuWindowOrderRule(PTU_START_FIELD, "end")(form({ [PTU_START_FIELD]: end })).validator(null, start), + ).rejects.toThrow("PTU Effective To must be after PTU Effective From"); + }); + + it("rejects a zero-length window, which the backend also refuses", async () => { + await expect( + ptuWindowOrderRule(PTU_END_FIELD, "start")(form({ [PTU_END_FIELD]: start })).validator(null, start), + ).rejects.toThrow("must be after"); + }); + + it("stays silent while either bound is empty, since the window is optional", async () => { + await expect(ptuWindowOrderRule(PTU_END_FIELD, "start")(form({})).validator(null, start)).resolves.toBeUndefined(); + await expect( + ptuWindowOrderRule(PTU_START_FIELD, "end")(form({ [PTU_START_FIELD]: start })).validator(null, ""), + ).resolves.toBeUndefined(); + }); +}); + +describe("backend maximums are mirrored in the form", () => { + it("rejects a count above the cap and accepts one at it", async () => { + await expect(ptuCountRules[0].validator(null, String(MAX_PTU_COUNT + 1))).rejects.toThrow("1,000,000"); + await expect(ptuCountRules[0].validator(null, String(MAX_PTU_COUNT))).resolves.toBeUndefined(); + }); + + it("rejects a rate above the cap and accepts one at it", async () => { + await expect(ptuRateRules[0].validator(null, String(MAX_COST_PER_PTU_PER_HOUR + 1))).rejects.toThrow("1,000,000"); + await expect(ptuRateRules[0].validator(null, String(MAX_COST_PER_PTU_PER_HOUR))).resolves.toBeUndefined(); + }); + + it("still accepts a zero rate, which the backend allows", async () => { + await expect(ptuRateRules[0].validator(null, "0")).resolves.toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/ptuValidation.ts b/ui/litellm-dashboard/src/utils/ptuValidation.ts new file mode 100644 index 00000000000..9fabf39ae23 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/ptuValidation.ts @@ -0,0 +1,116 @@ +interface ValidatorRule { + validator: (rule: unknown, value: unknown) => Promise; +} + +interface FormInstance { + getFieldValue: (name: string) => unknown; +} + +export const PTU_COUNT_FIELD = "ptu_count"; +export const PTU_RATE_FIELD = "cost_per_ptu_per_hour"; +export const PTU_START_FIELD = "ptu_effective_from"; +export const PTU_END_FIELD = "ptu_effective_to"; + +// Mirrors ModelInfo.MAX_PTU_COUNT / MAX_COST_PER_PTU_PER_HOUR. Flat cost multiplies the +// count by a float, so the backend caps both; without the same ceiling here the form +// reports valid input and the save then fails with a 422 the operator cannot anticipate. +export const MAX_PTU_COUNT = 1_000_000; +export const MAX_COST_PER_PTU_PER_HOUR = 1_000_000; + +const isFilled = (value: unknown): boolean => value !== undefined && value !== null && value !== ""; + +const isPositiveWholeNumber = (value: unknown): boolean => { + if (!isFilled(value)) { + return true; + } + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_PTU_COUNT; +}; + +/** Mirrors the backend contract, which rejects a ptu_count that is not a positive integer. */ +export const ptuCountRules: ValidatorRule[] = [ + { + validator: (_, value) => + isPositiveWholeNumber(value) + ? Promise.resolve() + : Promise.reject(new Error(`PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`)), + }, +]; + +const isNonNegativeNumber = (value: unknown): boolean => { + if (!isFilled(value)) { + return true; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= MAX_COST_PER_PTU_PER_HOUR; +}; + +/** Mirrors the backend contract, which rejects a negative cost_per_ptu_per_hour. */ +export const ptuRateRules: ValidatorRule[] = [ + { + validator: (_, value) => + isNonNegativeNumber(value) + ? Promise.resolve() + : Promise.reject( + new Error(`Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`), + ), + }, +]; + +/** + * The backend rejects a half-set pair with "ptu_count and cost_per_ptu_per_hour must be set + * together", so filling or clearing one field without the other is caught in the form. Pair + * this with `dependencies` on the sibling field so its error clears when the pair is resolved. + */ +export const ptuPairRule = + (siblingField: string) => + ({ getFieldValue }: FormInstance): ValidatorRule => ({ + validator: (_, value) => + isFilled(value) === isFilled(getFieldValue(siblingField)) + ? Promise.resolve() + : Promise.reject(new Error("PTU Count and Cost per PTU / Hour must be set together")), + }); + +/** + * The backend requires an effective start whenever PTU is configured, since flat cost + * accrues from that instant and an inferred start would bill days a deployment did not + * exist. Pair this with `dependencies` on the count so the error clears when both resolve. + */ +export const ptuStartRequiredRule = + (countField: string) => + ({ getFieldValue }: FormInstance): ValidatorRule => ({ + validator: (_, value) => + isFilled(value) || !isFilled(getFieldValue(countField)) + ? Promise.resolve() + : Promise.reject(new Error("PTU Effective From is required when PTU Count is set")), + }); + +/** Milliseconds for a picker value, which arrives as a Dayjs (or a Date/ISO string in tests). */ +const toEpochMs = (value: unknown): number => { + const raw = (value as { valueOf?: () => unknown } | null)?.valueOf?.(); + const asNumber = Number(raw); + return Number.isFinite(asNumber) ? asNumber : new Date(String(value)).getTime(); +}; + +/** + * The backend rejects a window whose end is not strictly after its start, so an inverted or + * zero-length window is caught in the form rather than answered with a 422 the operator + * cannot anticipate. Pair this with `dependencies` on the sibling bound so the error clears + * once the pair is ordered. + */ +export const ptuWindowOrderRule = + (siblingField: string, thisBound: "start" | "end") => + ({ getFieldValue }: FormInstance): ValidatorRule => ({ + validator: (_, value) => { + const sibling = getFieldValue(siblingField); + if (!isFilled(value) || !isFilled(sibling)) { + return Promise.resolve(); + } + const startMs = toEpochMs(thisBound === "start" ? value : sibling); + const endMs = toEpochMs(thisBound === "start" ? sibling : value); + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs > startMs) { + return Promise.resolve(); + } + return Promise.reject(new Error("PTU Effective To must be after PTU Effective From")); + }, + });