feat(ptu): PTU inputs on the model form and flat cost on the Usage page (#35393)

Add PTU count, cost per PTU per hour, and effective-from/to date-time pickers to
the model add and edit forms; the create submit and the edit save map the picker
values to model_info as ISO strings

On the Team Usage Cost tab the money tile becomes Total Cost once a team has
accrued flat cost, and expands to a Request Cost and Flat Cost breakdown, so the
summary row stays at five tiles and the cards keep their width. Each of the three
carries a tooltip, including that flat cost is reported rather than charged
against budgets. The Daily Spend chart stacks Flat cost on Request cost, with a
tooltip that splits the two and shows the total

A team that has accrued no flat cost renders exactly as it did before, and other
entity views are unchanged. CSV export gains Flat Cost and Total Cost columns
when a team accrued non-zero flat cost; the existing Spend header is left alone
so downstream parsers keep working

Both forms validate the PTU pair through one shared module. The count rule rejects
a fractional, zero or negative value, and a rate rule rejects a negative one, each
mirroring a contract the backend enforces. Keeping the rate rule shared rather than
on a single form is deliberate: the edit form previously validated only the count,
so a negative rate typed past the input's min reached the backend and failed the
save with a 400 the operator had no way to anticipate

Both forms require PTU Effective From once PTU Count is set, matching the backend, which
rejects PTU config without a start because flat cost accrues from that instant and an
inferred one would bill days a deployment did not exist. The rule lives beside the count
and rate rules in the shared module, so the add and edit paths cannot drift.
This commit is contained in:
yucheng-berri 2026-08-10 11:05:06 -07:00 • committed by GitHub
parent 0580465384
commit ed242098ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1024 additions and 59 deletions

View file

@ -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<EntityUsageProps> = ({ accessToken, entityType, enti
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const [topAgentsLimit, setTopAgentsLimit] = useState<number>(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<EntityUsageProps> = ({ 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 ? <DownOutlined className={chev} /> : <RightOutlined className={chev} />;
const infoIcon = <InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />;
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
<Card
key={title}
className={expandable ? "cursor-pointer hover:bg-gray-50 transition-colors" : undefined}
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
>
<div className="flex items-center gap-2">
<Title>{title}</Title>
{tooltip ? <Tooltip title={tooltip}>{infoIcon}</Tooltip> : null}
{expandable ? expandIcon : null}
</div>
<Text className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</Text>
</Card>
);
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 = (
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Card>
<Title>{capitalizedEntityLabel} Spend Overview</Title>
<Grid numItems={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>
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">{spendData.metadata.total_api_requests.toLocaleString()}</Text>
</Card>
<Card>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{spendData.metadata.total_successful_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Failed Requests</Title>
<Text className="text-2xl font-bold mt-2 text-red-600">
{spendData.metadata.total_failed_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Total Tokens</Title>
<Text className="text-2xl font-bold mt-2">{spendData.metadata.total_tokens.toLocaleString()}</Text>
</Card>
{summaryTiles.map(renderSummaryTile)}
</Grid>
</Card>
</Col>
@ -456,21 +456,40 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
</CardHeader>
<CardContent>
<BarChart
data={[...spendData.results].sort((a, b) => 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 (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
{showFlatCost ? (
<>
<p className="text-cyan-500">Request cost: ${formatNumberWithCommas(requestSpend, 2)}</p>
<p className="text-violet-500">Flat cost: ${formatNumberWithCommas(flatCost, 2)}</p>
<p className="font-semibold">
Total cost: ${formatNumberWithCommas(requestSpend + flatCost, 2)}
</p>
</>
) : (
<p className="text-cyan-500">Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
)}
<p className="text-gray-600">Total Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>

View file

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

View file

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

View file

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

View file

@ -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<string, any>, b: Record<string, any>): Record<string, any> {
/**
* 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<string, any>, b: Record<string, any>): Record<string, any> {
const result = { ...a };
for (const key of SUMMABLE_METADATA_KEYS) {
result[key] = (a[key] || 0) + (b[key] || 0);

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

View file

@ -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<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, data.metadata);
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);
});
});
@ -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,

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

@ -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<AdvancedSettingsProps> = ({
/>
</Form.Item>
<Form.Item
label="PTU Count"
name={PTU_COUNT_FIELD}
dependencies={[PTU_RATE_FIELD]}
rules={[{ validator: validateNumber }, ...ptuCountRules, ptuPairRule(PTU_RATE_FIELD)]}
tooltip="Provisioned throughput units for this deployment. Set together with Cost per PTU / Hour and a Team to attribute a flat daily cost."
className="mb-4"
>
<TextInput placeholder="e.g. 15" />
</Form.Item>
<Form.Item
label="Calculated Cost per PTU / Hour (USD)"
name={PTU_RATE_FIELD}
dependencies={[PTU_COUNT_FIELD]}
rules={[{ validator: validateNumber }, ...ptuRateRules, ptuPairRule(PTU_COUNT_FIELD)]}
tooltip="Flat cost = PTU count * this rate * active hours, attributed to the deployment's team."
className="mb-4"
>
<TextInput placeholder="e.g. 2.00" />
</Form.Item>
<Form.Item
label="PTU Effective From (UTC)"
name={PTU_START_FIELD}
dependencies={[PTU_COUNT_FIELD, PTU_END_FIELD]}
rules={[ptuStartRequiredRule(PTU_COUNT_FIELD), ptuWindowOrderRule(PTU_END_FIELD, "start")]}
tooltip="Start of the PTU window, required when PTU Count is set. Flat cost accrues by the hour within the window; a window opening at 23:00 charges one hour that day."
className="mb-4"
>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label="PTU Effective To (UTC)"
name={PTU_END_FIELD}
dependencies={[PTU_START_FIELD]}
rules={[ptuWindowOrderRule(PTU_START_FIELD, "end")]}
tooltip="Optional end of the PTU window (exclusive). Leave blank for open-ended."
className="mb-4"
>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
{customPricing && (
<div className="ml-6 pl-4 border-l-2 border-gray-200">
<Form.Item label="Pricing Model" name="pricing_model" className="mb-4">

View file

@ -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<string, any>, accessToken: string, form: any) => {
try {
@ -163,6 +164,23 @@ export const prepareModelAddRequest = async (formValues: Record<string, any>, 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

View file

@ -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({
)}
</div>
{PTU_EDIT_FIELDS.map((ptuField) => {
const { name, label, input, placeholder, isCount, isRate, isStart, pairedWith } = ptuField;
const { windowPeer, bound } = ptuField;
return (
<div key={name}>
<Text className="font-medium">{label}</Text>
{isEditing ? (
<Form.Item
name={name}
className="mb-0"
dependencies={ptuFieldDependencies(ptuField)}
rules={[
...(isCount ? ptuCountRules : []),
...(isRate ? ptuRateRules : []),
...(isStart ? [ptuStartRequiredRule(PTU_COUNT_FIELD)] : []),
...(pairedWith ? [ptuPairRule(pairedWith)] : []),
...(windowPeer && bound ? [ptuWindowOrderRule(windowPeer, bound)] : []),
]}
>
{input === "number" ? (
<NumericalInput
placeholder={placeholder}
step={isCount ? 1 : undefined}
min={isCount ? 1 : 0}
/>
) : (
<DatePicker showTime style={{ width: "100%" }} />
)}
</Form.Item>
) : (
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
{(input === "datetime"
? formatPtuUtcDisplay(localModelData?.model_info?.[name])
: localModelData?.model_info?.[name]) ?? "Not Set"}
</div>
)}
</div>
);
})}
<div>
<Text className="font-medium">Cache Read Cost (per 1M tokens)</Text>
{isEditing ? (

View file

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

View file

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

View file

@ -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<string, unknown>) => ({ 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();
});
});

View file

@ -0,0 +1,116 @@
interface ValidatorRule {
validator: (rule: unknown, value: unknown) => Promise<void>;
}
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"));
},
});