mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
0580465384
commit
ed242098ba
16 changed files with 1024 additions and 59 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface EntitySpendData {
|
|||
results: any[];
|
||||
metadata: {
|
||||
total_spend: number;
|
||||
total_flat_cost?: number;
|
||||
total_api_requests: number;
|
||||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
|
|
@ -38,6 +39,8 @@ export interface ExportMetadata {
|
|||
export_scope: ExportScope;
|
||||
summary: {
|
||||
total_spend: number;
|
||||
total_flat_cost?: number;
|
||||
total_cost?: number;
|
||||
total_requests: number;
|
||||
successful_requests: number;
|
||||
failed_requests: number;
|
||||
|
|
|
|||
|
|
@ -1861,6 +1861,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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface SpendMetrics {
|
||||
spend: number;
|
||||
flat_cost?: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
96
ui/litellm-dashboard/src/utils/ptuDatetime.test.ts
Normal file
96
ui/litellm-dashboard/src/utils/ptuDatetime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
61
ui/litellm-dashboard/src/utils/ptuDatetime.ts
Normal file
61
ui/litellm-dashboard/src/utils/ptuDatetime.ts
Normal 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);
|
||||
};
|
||||
170
ui/litellm-dashboard/src/utils/ptuValidation.test.ts
Normal file
170
ui/litellm-dashboard/src/utils/ptuValidation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
116
ui/litellm-dashboard/src/utils/ptuValidation.ts
Normal file
116
ui/litellm-dashboard/src/utils/ptuValidation.ts
Normal 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"));
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue