mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(ui): show the guardrail cost math in a popover table
The "How is this calculated?" hover was a plain-text tooltip. It is now a popover (opens on hover or click) with a title, the formula, a table of one row per counter or guardrail (units, × price, = cost, with unpriced units called out under the row) and a total row, so the math reads as a worked sum instead of a sentence. Refs LIT-5652
This commit is contained in:
parent
e66ba0533f
commit
29b93b57aa
9 changed files with 204 additions and 106 deletions
|
|
@ -85,20 +85,33 @@ describe("GuardrailUsageBreakdown", () => {
|
|||
expect(within(unpricedKey).getByText("7", { selector: ".text-warning" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains the cost math per counter on hover", async () => {
|
||||
const cellsOf = (dialog: HTMLElement): string[][] =>
|
||||
within(dialog)
|
||||
.getAllByRole("row")
|
||||
.map((row) =>
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent ?? ""),
|
||||
);
|
||||
|
||||
it("lays the cost math out per counter as units × price = cost", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
await user.hover(
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Content Policy: 1,000 × $0.00015 = $0.1500")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sensitive Information Policy: 300 × $0.0001 = $0.0300")).toBeInTheDocument();
|
||||
expect(screen.getByText("Some Future Counter: 7 units with no known price, left out")).toBeInTheDocument();
|
||||
expect(screen.getByText("Total: $0.1800")).toBeInTheDocument();
|
||||
expect(screen.getByText(/7 units with no known price are left out of the cost/)).toBeInTheDocument();
|
||||
const issueLink = screen.getByRole("link", { name: "Request pricing on GitHub" });
|
||||
const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" });
|
||||
expect(cellsOf(dialog)).toEqual([
|
||||
["Content Policy", "1,000", "× $0.00015", "= $0.1500"],
|
||||
["Sensitive Information Policy", "300", "× $0.0001", "= $0.0300"],
|
||||
["Some Future Counter", "7", "× —", "= —"],
|
||||
["no known price, left out"],
|
||||
["Total", "$0.1800"],
|
||||
]);
|
||||
expect(within(dialog).getByText(/7 units with no known price are left out of the cost/)).toBeInTheDocument();
|
||||
const issueLink = within(dialog).getByRole("link", { name: "Request pricing on GitHub" });
|
||||
expect(issueLink).toHaveAttribute("target", "_blank");
|
||||
const issueUrl = new URL(issueLink.getAttribute("href") ?? "");
|
||||
expect(issueUrl.searchParams.get("title")).toBe("[Feature]: add Bedrock guardrail pricing to the cost map");
|
||||
|
|
@ -119,29 +132,35 @@ describe("GuardrailUsageBreakdown", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await user.hover(
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Total: $0.1500")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Request pricing on GitHub" })).not.toBeInTheDocument();
|
||||
const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" });
|
||||
expect(cellsOf(dialog)).toEqual([
|
||||
["Content Policy", "1,000", "× $0.00015", "= $0.1500"],
|
||||
["Total", "$0.1500"],
|
||||
]);
|
||||
expect(within(dialog).queryByRole("link", { name: "Request pricing on GitHub" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains the units sum on hover", async () => {
|
||||
it("lays the units sum out per counter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GuardrailUsageBreakdown detail={detail} />);
|
||||
|
||||
await user.hover(
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Usage Units" })).getByRole("button", {
|
||||
name: /How is this calculated/,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Content Policy 1,000 + Sensitive Information Policy 300 + Some Future Counter 7 = 1,307",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const dialog = await screen.findByRole("dialog", { name: "How usage units add up" });
|
||||
expect(cellsOf(dialog)).toEqual([
|
||||
["Content Policy", "1,000"],
|
||||
["Sensitive Information Policy", "300"],
|
||||
["Some Future Counter", "7"],
|
||||
["Total", "1,307"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders teams and keys by units, largest first", () => {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ import type { ColumnDef } from "@tanstack/react-table";
|
|||
import { CircleDollarSign } from "lucide-react";
|
||||
import React from "react";
|
||||
import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { CalcPopover, MathTable } from "@/components/GuardrailsMonitor/CalcPopover";
|
||||
import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard";
|
||||
import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote";
|
||||
import {
|
||||
counterLabel,
|
||||
counterMathLine,
|
||||
counterMathRow,
|
||||
formatCost,
|
||||
totalUnits,
|
||||
unitsSumLine,
|
||||
unitsMathRows,
|
||||
unpricedSummary,
|
||||
} from "@/components/GuardrailsMonitor/usageUnits";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
|
@ -113,21 +114,20 @@ const teamColumns = groupColumns("Team", "No team");
|
|||
const keyColumns = groupColumns("Key", "No key");
|
||||
|
||||
const CostMath = ({ counters, detail }: { counters: CounterRow[]; detail: GuardrailUsageDetail }) => (
|
||||
<div className="space-y-1">
|
||||
{counters.map((row) => (
|
||||
<div key={row.counter}>{counterMathLine(row)}</div>
|
||||
))}
|
||||
<div className="font-medium">Total: {formatCost(detail.cost)}</div>
|
||||
<div>Each counter is its priced units × the per-unit price LiteLLM has for it in the cost map.</div>
|
||||
<CalcPopover title="How this cost is calculated" formula="priced units × price per unit = cost, per counter">
|
||||
<MathTable rows={counters.map(counterMathRow)} total={formatCost(detail.cost)} />
|
||||
<p className="text-xs text-muted-foreground">Per-unit prices come from the cost map LiteLLM ships with.</p>
|
||||
<UnpricedNote unpriced={detail.untracked_usage_units} provider={detail.provider} />
|
||||
</div>
|
||||
</CalcPopover>
|
||||
);
|
||||
|
||||
const UnitsMath = ({ units }: { units: GuardrailUsageDetail["usage_units"] }) => (
|
||||
<div className="space-y-1">
|
||||
<div>{unitsSumLine(units)}</div>
|
||||
<div>Units are the billable counters the provider reported for this guardrail, added up over every call.</div>
|
||||
</div>
|
||||
<CalcPopover title="How usage units add up" formula="counter + counter + … = usage units">
|
||||
<MathTable rows={unitsMathRows(units)} total={totalUnits(units).toLocaleString()} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Units are the billable counters the provider reported for this guardrail, added up over every call.
|
||||
</p>
|
||||
</CalcPopover>
|
||||
);
|
||||
|
||||
const TableHeading = ({ title }: { title: string }) => (
|
||||
|
|
|
|||
|
|
@ -211,19 +211,28 @@ describe("GuardrailsOverview", () => {
|
|||
expect(card).toHaveTextContent("250 units unpriced");
|
||||
});
|
||||
|
||||
it("explains the guardrail cost total on hover", async () => {
|
||||
it("lays the guardrail cost total out per guardrail", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOverview();
|
||||
|
||||
const card = await screen.findByRole("group", { name: "Guardrail Cost" });
|
||||
await user.hover(within(card).getByRole("button", { name: /How is this calculated/ }));
|
||||
await user.click(within(card).getByRole("button", { name: /How is this calculated/ }));
|
||||
|
||||
expect(await screen.findByText("High Failure Guardrail: $0.1500")).toBeInTheDocument();
|
||||
expect(screen.getByText("Free Bedrock Guardrail: $0.0000")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Low Failure Guardrail: /)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Total: $0.1500")).toBeInTheDocument();
|
||||
expect(screen.getByText(/250 units with no known price are left out of the cost/)).toBeInTheDocument();
|
||||
const issueLink = screen.getByRole("link", { name: "Request pricing on GitHub" });
|
||||
const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" });
|
||||
const cells = within(dialog)
|
||||
.getAllByRole("row")
|
||||
.map((row) =>
|
||||
within(row)
|
||||
.getAllByRole("cell")
|
||||
.map((cell) => cell.textContent ?? ""),
|
||||
);
|
||||
expect(cells).toEqual([
|
||||
["High Failure Guardrail", "$0.1500"],
|
||||
["Free Bedrock Guardrail", "$0.0000"],
|
||||
["Total", "$0.1500"],
|
||||
]);
|
||||
expect(within(dialog).getByText(/250 units with no known price are left out of the cost/)).toBeInTheDocument();
|
||||
const issueLink = within(dialog).getByRole("link", { name: "Request pricing on GitHub" });
|
||||
const issueUrl = new URL(issueLink.getAttribute("href") ?? "");
|
||||
expect(issueUrl.searchParams.get("template")).toBe("feature_request.yml");
|
||||
expect(issueUrl.searchParams.get("the-feature")).toContain("sensitiveInformationPolicyUnits");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type GuardrailUsageOverviewRow,
|
||||
useGuardrailsUsageOverview,
|
||||
} from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage";
|
||||
import { CalcPopover, MathTable } from "@/components/GuardrailsMonitor/CalcPopover";
|
||||
import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote";
|
||||
import {
|
||||
counterLabel,
|
||||
|
|
@ -80,20 +81,18 @@ function TotalCostMath({
|
|||
untracked: UsageUnits;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{rows
|
||||
.filter((row) => row.cost != null)
|
||||
.map((row) => (
|
||||
<div key={row.id}>
|
||||
{row.name}: {formatCost(row.cost)}
|
||||
</div>
|
||||
))}
|
||||
<div className="font-medium">Total: {formatCost(total)}</div>
|
||||
<div>
|
||||
{`Each guardrail's cost is its units per counter × that counter's per-unit price from the cost map, added up. Open a guardrail for its per-counter math.`}
|
||||
</div>
|
||||
<CalcPopover title="How this cost is calculated" formula="guardrail + guardrail + … = guardrail cost">
|
||||
<MathTable
|
||||
rows={rows
|
||||
.filter((row) => row.cost != null)
|
||||
.map((row) => ({ label: row.name, parts: [formatCost(row.cost)], note: null }))}
|
||||
total={formatCost(total)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{`Each guardrail's cost is its units per counter × that counter's per-unit price from the cost map. Open a guardrail for its per-counter math.`}
|
||||
</p>
|
||||
<UnpricedNote unpriced={untracked} />
|
||||
</div>
|
||||
</CalcPopover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { MathRow } from "./usageUnits";
|
||||
|
||||
export function CalcPopover({ title, formula, children }: { title: string; formula: string; children: ReactNode }) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={200}
|
||||
closeDelay={150}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex w-fit cursor-help items-start gap-1 text-left text-xs text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CircleHelp className="mt-px size-3.5 shrink-0" />
|
||||
How is this calculated?
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="w-auto min-w-72 max-w-md gap-3">
|
||||
<PopoverTitle>{title}</PopoverTitle>
|
||||
<code className="w-fit rounded bg-muted px-2 py-1 text-[11px] text-muted-foreground">{formula}</code>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function MathTable({ rows, total }: { rows: readonly MathRow[]; total: string }) {
|
||||
const width = 1 + Math.max(...rows.map((row) => row.parts.length), 1);
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<React.Fragment key={row.label}>
|
||||
<tr>
|
||||
<td className="py-0.5 pr-3">{row.label}</td>
|
||||
{row.parts.map((part, i) => (
|
||||
<td key={i} className="py-0.5 pl-3 text-right whitespace-nowrap tabular-nums">
|
||||
{part}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{row.note && (
|
||||
<tr>
|
||||
<td colSpan={width} className="pb-1 text-[11px] text-warning">
|
||||
{row.note}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-border font-medium">
|
||||
<td className="pt-1.5 pr-3" colSpan={width - 1}>
|
||||
Total
|
||||
</td>
|
||||
<td className="pt-1.5 pl-3 text-right whitespace-nowrap tabular-nums">{total}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
|
|
@ -20,26 +18,7 @@ export function MetricCard({ label, value, valueColor = "text-foreground", icon,
|
|||
</div>
|
||||
<div className={`text-3xl font-semibold ${valueColor} tracking-tight`}>{value}</div>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
{hint && (
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 inline-flex w-fit cursor-help items-start gap-1 text-left text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CircleHelp className="mt-px size-3.5 shrink-0" />
|
||||
How is this calculated?
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-sm">
|
||||
{hint}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export function UnpricedNote({ unpriced, provider }: { unpriced: UsageUnits; pro
|
|||
if (total === 0) return null;
|
||||
const [noun, verb] = total === 1 ? ["unit", "is"] : ["units", "are"];
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-warning">
|
||||
{`${total.toLocaleString()} ${noun} with no known price ${verb} left out of the cost. `}
|
||||
<a
|
||||
href={pricingIssueUrl(unpriced, provider)}
|
||||
|
|
@ -16,6 +16,6 @@ export function UnpricedNote({ unpriced, provider }: { unpriced: UsageUnits; pro
|
|||
>
|
||||
Request pricing on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
counterLabel,
|
||||
counterMathLine,
|
||||
counterMathRow,
|
||||
formatCost,
|
||||
formatUnitPrice,
|
||||
pricingIssueUrl,
|
||||
totalUnits,
|
||||
unitPrice,
|
||||
unitsSumLine,
|
||||
unitsMathRows,
|
||||
unpricedSummary,
|
||||
} from "./usageUnits";
|
||||
|
||||
|
|
@ -91,40 +91,51 @@ describe("formatUnitPrice", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("counterMathLine", () => {
|
||||
describe("counterMathRow", () => {
|
||||
it("shows units × price = cost for a fully priced counter", () => {
|
||||
expect(counterMathLine({ counter: "contentPolicyUnits", units: 1000, unpriced: 0, cost: 0.15 })).toBe(
|
||||
"Content Policy: 1,000 × $0.00015 = $0.1500",
|
||||
);
|
||||
expect(counterMathRow({ counter: "contentPolicyUnits", units: 1000, unpriced: 0, cost: 0.15 })).toEqual({
|
||||
label: "Content Policy",
|
||||
parts: ["1,000", "× $0.00015", "= $0.1500"],
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("prices only the priced share and calls out the rest", () => {
|
||||
expect(counterMathLine({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 2, cost: 0.0006 })).toBe(
|
||||
"Sensitive Information Policy: 6 × $0.0001 = $0.0006 (2 unpriced left out)",
|
||||
expect(counterMathRow({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 2, cost: 0.0006 })).toEqual(
|
||||
{
|
||||
label: "Sensitive Information Policy",
|
||||
parts: ["6", "× $0.0001", "= $0.0006"],
|
||||
note: "2 unpriced units left out",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
counterMathRow({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 1, cost: 0.0007 }).note,
|
||||
).toBe("1 unpriced unit left out");
|
||||
});
|
||||
|
||||
it("says so when a counter has no known price at all", () => {
|
||||
expect(counterMathLine({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toBe(
|
||||
"Some Future Counter: 7 units with no known price, left out",
|
||||
);
|
||||
expect(counterMathLine({ counter: "someFutureCounter", units: 1, unpriced: 1, cost: null })).toBe(
|
||||
"Some Future Counter: 1 unit with no known price, left out",
|
||||
);
|
||||
expect(counterMathRow({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toEqual({
|
||||
label: "Some Future Counter",
|
||||
parts: ["7", "× —", "= —"],
|
||||
note: "no known price, left out",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a free counter as × $0", () => {
|
||||
expect(counterMathLine({ counter: "wordPolicyUnits", units: 2, unpriced: 0, cost: 0 })).toBe(
|
||||
"Word Policy: 2 × $0 = $0.0000",
|
||||
);
|
||||
expect(counterMathRow({ counter: "wordPolicyUnits", units: 2, unpriced: 0, cost: 0 }).parts).toEqual([
|
||||
"2",
|
||||
"× $0",
|
||||
"= $0.0000",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unitsSumLine", () => {
|
||||
it("adds the counters up in order", () => {
|
||||
expect(unitsSumLine({ contentPolicyUnits: 2, topicPolicyUnits: 2, wordPolicyUnits: 1200 })).toBe(
|
||||
"Content Policy 2 + Topic Policy 2 + Word Policy 1,200 = 1,204",
|
||||
);
|
||||
describe("unitsMathRows", () => {
|
||||
it("lists the counters in order with their counts", () => {
|
||||
expect(unitsMathRows({ contentPolicyUnits: 2, wordPolicyUnits: 1200 })).toEqual([
|
||||
{ label: "Content Policy", parts: ["2"], note: null },
|
||||
{ label: "Word Policy", parts: ["1,200"], note: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,20 +40,34 @@ export const formatUnitPrice = (price: number): string => {
|
|||
return price > 0 && Number(fixed) === 0 ? "< $0.000001" : `$${fixed}`;
|
||||
};
|
||||
|
||||
export const counterMathLine = (row: CounterMath): string => {
|
||||
export interface MathRow {
|
||||
readonly label: string;
|
||||
readonly parts: readonly string[];
|
||||
readonly note: string | null;
|
||||
}
|
||||
|
||||
export const counterMathRow = (row: CounterMath): MathRow => {
|
||||
const label = counterLabel(row.counter);
|
||||
const price = unitPrice(row);
|
||||
if (price == null) {
|
||||
return `${label}: ${row.units.toLocaleString()} ${row.units === 1 ? "unit" : "units"} with no known price, left out`;
|
||||
return { label, parts: [row.units.toLocaleString(), "× —", "= —"], note: "no known price, left out" };
|
||||
}
|
||||
const line = `${label}: ${pricedUnits(row).toLocaleString()} × ${formatUnitPrice(price)} = ${formatCost(row.cost)}`;
|
||||
return row.unpriced > 0 ? `${line} (${row.unpriced.toLocaleString()} unpriced left out)` : line;
|
||||
return {
|
||||
label,
|
||||
parts: [pricedUnits(row).toLocaleString(), `× ${formatUnitPrice(price)}`, `= ${formatCost(row.cost)}`],
|
||||
note:
|
||||
row.unpriced > 0
|
||||
? `${row.unpriced.toLocaleString()} unpriced ${row.unpriced === 1 ? "unit" : "units"} left out`
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const unitsSumLine = (units: UsageUnits): string =>
|
||||
`${Object.entries(units)
|
||||
.map(([counter, n]) => `${counterLabel(counter)} ${n.toLocaleString()}`)
|
||||
.join(" + ")} = ${totalUnits(units).toLocaleString()}`;
|
||||
export const unitsMathRows = (units: UsageUnits): readonly MathRow[] =>
|
||||
Object.entries(units).map(([counter, n]) => ({
|
||||
label: counterLabel(counter),
|
||||
parts: [n.toLocaleString()],
|
||||
note: null,
|
||||
}));
|
||||
|
||||
export const pricingIssueUrl = (unpriced: UsageUnits, provider?: string): string => {
|
||||
const subject = provider ? `${provider} guardrail` : "guardrail";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue