mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(ui): show per-second pricing for video models instead of $0.00 token costs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
31ca4ddf32
commit
dfc74d3806
8 changed files with 183 additions and 20 deletions
|
|
@ -183,6 +183,39 @@ describe("AllModelsTable", () => {
|
|||
expect(screen.queryByText(/^\$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the per-second rate instead of $0.00 token costs for a video model priced per second", () => {
|
||||
const { rerender } = render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[
|
||||
makeModel({
|
||||
input_cost: "0.00" as unknown as number,
|
||||
output_cost: "0.00" as unknown as number,
|
||||
output_cost_per_second: 0.4,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$0.40/s")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
data={[
|
||||
makeModel({
|
||||
input_cost: "0.60" as unknown as number,
|
||||
output_cost: "0.00" as unknown as number,
|
||||
output_cost_per_second: 0.015,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("$0.60")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.015/s")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses extra access groups behind a +N more badge", () => {
|
||||
render(
|
||||
<AllModelsTable
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { formatPerSecondCost } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
|
|
@ -181,30 +182,33 @@ function CreatedByCell({ model }: { model: ModelData }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CostsCell({ model }: { model: ModelData }) {
|
||||
const { input_cost: inputCost, output_cost: outputCost } = model;
|
||||
function CostRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-semibold tracking-wider text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (inputCost == null && outputCost == null) {
|
||||
function CostsCell({ model }: { model: ModelData }) {
|
||||
const { input_cost: inputCost, output_cost: outputCost, output_cost_per_second: perSecond } = model;
|
||||
const hasPerSecond = perSecond != null;
|
||||
const showInput = inputCost != null && (!hasPerSecond || Number(inputCost) > 0);
|
||||
const showOutput = outputCost != null && (!hasPerSecond || Number(outputCost) > 0);
|
||||
|
||||
if (!showInput && !showOutput && !hasPerSecond) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CellTooltip
|
||||
content="Cost per 1M tokens"
|
||||
content={hasPerSecond ? "Cost per 1M tokens; /s is cost per second of output" : "Cost per 1M tokens"}
|
||||
trigger={
|
||||
<div className="flex flex-col gap-0.5 whitespace-nowrap">
|
||||
{inputCost != null && (
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-semibold tracking-wider text-muted-foreground">IN</span>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground">${inputCost}</span>
|
||||
</span>
|
||||
)}
|
||||
{outputCost != null && (
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] font-semibold tracking-wider text-muted-foreground">OUT</span>
|
||||
<span className="text-xs font-medium tabular-nums text-foreground">${outputCost}</span>
|
||||
</span>
|
||||
)}
|
||||
{showInput && <CostRow label="IN" value={`$${inputCost}`} />}
|
||||
{showOutput && <CostRow label="OUT" value={`$${outputCost}`} />}
|
||||
{hasPerSecond && <CostRow label="OUT" value={formatPerSecondCost(perSecond)} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -101,6 +101,53 @@ describe("transformModelData", () => {
|
|||
expect(result.data[0].output_cost).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps per-second pricing and resolution tiers for video models priced per second", () => {
|
||||
const rawData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "veo-3.1-fast",
|
||||
litellm_params: { model: "vertex_ai/veo-3.1-fast-generate-001" },
|
||||
model_info: {
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
output_cost_per_second: 0.1,
|
||||
output_cost_per_second_1080p: 0.12,
|
||||
output_cost_per_second_4k: 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
litellm_params: { model: "gpt-4" },
|
||||
model_info: { input_cost_per_token: 0.0000015, output_cost_per_token: 0.000002 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformModelData(rawData, mockGetProviderFromModel);
|
||||
|
||||
expect(result.data[0].output_cost_per_second).toBe(0.1);
|
||||
expect(result.data[0].output_cost_per_second_tiers).toEqual([
|
||||
{ resolution: "1080p", cost: 0.12 },
|
||||
{ resolution: "4k", cost: 0.3 },
|
||||
]);
|
||||
expect(result.data[1].output_cost_per_second).toBeNull();
|
||||
expect(result.data[1].output_cost_per_second_tiers).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers a per-second override from litellm_params over model_info", () => {
|
||||
const rawData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "veo-3.1",
|
||||
litellm_params: { model: "vertex_ai/veo-3.1-generate-001", output_cost_per_second: 0.5 },
|
||||
model_info: { output_cost_per_second: 0.4 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(transformModelData(rawData, mockGetProviderFromModel).data[0].output_cost_per_second).toBe(0.5);
|
||||
});
|
||||
|
||||
it("should handle missing model_info", () => {
|
||||
const rawData = {
|
||||
data: [
|
||||
|
|
|
|||
|
|
@ -1,3 +1,16 @@
|
|||
import { PerSecondCostTier } from "@/components/model_dashboard/types";
|
||||
|
||||
const PER_SECOND_TIER_KEY = /^output_cost_per_second_(.+)$/;
|
||||
|
||||
export const perSecondCostTiers = (modelInfo: Record<string, unknown> | null | undefined): PerSecondCostTier[] =>
|
||||
Object.entries(modelInfo ?? {}).flatMap(([key, value]) => {
|
||||
const resolution = PER_SECOND_TIER_KEY.exec(key)?.[1];
|
||||
return resolution !== undefined && typeof value === "number" ? [{ resolution, cost: value }] : [];
|
||||
});
|
||||
|
||||
export const formatPerSecondCost = (cost: number): string =>
|
||||
`$${cost.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 })}/s`;
|
||||
|
||||
/**
|
||||
* Utility function to transform raw model data into the format expected by UI components
|
||||
* This creates a new transformed data object without mutating the original
|
||||
|
|
@ -55,6 +68,9 @@ export const transformModelData = (rawModelData: any, getProviderFromModel: (mod
|
|||
transformedData[i].provider = provider;
|
||||
transformedData[i].input_cost = input_cost;
|
||||
transformedData[i].output_cost = output_cost;
|
||||
transformedData[i].output_cost_per_second =
|
||||
curr_model?.litellm_params?.output_cost_per_second ?? model_info?.output_cost_per_second ?? null;
|
||||
transformedData[i].output_cost_per_second_tiers = perSecondCostTiers(model_info);
|
||||
transformedData[i].litellm_model_name = litellm_model_name;
|
||||
|
||||
// Convert Cost in terms of Cost per 1M tokens
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
export interface PerSecondCostTier {
|
||||
resolution: string;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
created_at: string;
|
||||
|
|
@ -27,6 +32,8 @@ export interface ModelData {
|
|||
litellm_model_name: string;
|
||||
input_cost: number;
|
||||
output_cost: number;
|
||||
output_cost_per_second?: number | null;
|
||||
output_cost_per_second_tiers?: PerSecondCostTier[];
|
||||
max_tokens: number;
|
||||
max_input_tokens: number;
|
||||
api_base?: string;
|
||||
|
|
|
|||
|
|
@ -426,6 +426,37 @@ describe("ModelInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows per-second pricing with resolution tiers instead of $0.00 per 1M tokens for a video model", async () => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
...defaultModelData,
|
||||
model_name: "veo-3.1-fast",
|
||||
litellm_params: { model: "vertex_ai/veo-3.1-fast-generate-001" },
|
||||
model_info: {
|
||||
...defaultModelData.model_info,
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
output_cost_per_second: 0.1,
|
||||
output_cost_per_second_1080p: 0.12,
|
||||
output_cost_per_second_4k: 0.3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByText("Output: $0.10/s")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output (1080p): $0.12/s")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output (4k): $0.30/s")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/\$0\.00\/1M tokens/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display edit settings button when user can edit model", async () => {
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
testConnectionRequest,
|
||||
} from "./networking";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { ModelPricingSummary } from "@/components/molecules/models/ModelPricingSummary";
|
||||
import UpdateModelCredentialsModal from "./update_model_credentials_modal";
|
||||
import ModelInfoEditForm, { type ModelEditFormValues, type TouchedPricingField } from "./ModelInfoEditForm";
|
||||
import { Tag } from "./tag_management/types";
|
||||
|
|
@ -698,10 +699,7 @@ export default function ModelInfoView({
|
|||
</Card>
|
||||
<Card className="block p-6">
|
||||
<p className="text-sm">Pricing</p>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm">Input: ${modelData.input_cost}/1M tokens</p>
|
||||
<p className="text-sm">Output: ${modelData.output_cost}/1M tokens</p>
|
||||
</div>
|
||||
<ModelPricingSummary model={modelData} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { formatPerSecondCost } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
|
||||
import { ModelData } from "@/components/model_dashboard/types";
|
||||
|
||||
type PricingFields = Pick<
|
||||
ModelData,
|
||||
"input_cost" | "output_cost" | "output_cost_per_second" | "output_cost_per_second_tiers"
|
||||
>;
|
||||
|
||||
export function ModelPricingSummary({ model }: { model: PricingFields }) {
|
||||
const perSecond = model.output_cost_per_second;
|
||||
const hasPerSecond = perSecond != null;
|
||||
const showInput = !hasPerSecond || Number(model.input_cost) > 0;
|
||||
const showOutput = !hasPerSecond || Number(model.output_cost) > 0;
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{showInput && <p className="text-sm">Input: ${model.input_cost}/1M tokens</p>}
|
||||
{showOutput && <p className="text-sm">Output: ${model.output_cost}/1M tokens</p>}
|
||||
{hasPerSecond && <p className="text-sm">Output: {formatPerSecondCost(perSecond)}</p>}
|
||||
{(model.output_cost_per_second_tiers ?? []).map(({ resolution, cost }) => (
|
||||
<p key={resolution} className="text-sm">
|
||||
Output ({resolution}): {formatPerSecondCost(cost)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue