litellm/ui/litellm-dashboard/src/utils/ptuDatetime.ts
yucheng-berri ed242098ba
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.
2026-08-10 11:05:06 -07:00

61 lines
2.5 KiB
TypeScript

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