Merge pull request #39308 from BerriAI/litellm_ui_per_second_video_pricing

fix(ui): show per-second pricing for video models instead of $0.00 token costs
This commit is contained in:
ryan-crabbe-berri 2026-09-18 10:05:26 -07:00 committed by GitHub
commit a9ee15372f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 325 additions and 110 deletions

View file

@ -646,11 +646,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": {
"prefer-const": {
"count": 6
}
},
"src/app/(dashboard)/old-usage/_components/usage.tsx": { "src/app/(dashboard)/old-usage/_components/usage.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1

View file

@ -11,8 +11,8 @@ const makeModel = (overrides: Partial<ModelData> = {}): ModelData =>
model_name: "gpt-4-public", model_name: "gpt-4-public",
litellm_model_name: "openai/gpt-4", litellm_model_name: "openai/gpt-4",
provider: "openai", provider: "openai",
input_cost: 30 as unknown as number, input_cost: "30",
output_cost: 60 as unknown as number, output_cost: "60",
max_tokens: 8192, max_tokens: 8192,
max_input_tokens: 8192, max_input_tokens: 8192,
litellm_params: { model: "openai/gpt-4" }, litellm_params: { model: "openai/gpt-4" },
@ -175,13 +175,41 @@ describe("AllModelsTable", () => {
expect(screen.getByText("$30")).toBeInTheDocument(); expect(screen.getByText("$30")).toBeInTheDocument();
expect(screen.getByText("$60")).toBeInTheDocument(); expect(screen.getByText("$60")).toBeInTheDocument();
rerender(<AllModelsTable {...baseProps} data={[makeModel({ input_cost: null, output_cost: null })]} />);
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",
output_cost: "0.00",
output_cost_per_second: 0.4,
}),
]}
/>,
);
expect(screen.getByText("$0.40/s")).toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
rerender( rerender(
<AllModelsTable <AllModelsTable
{...baseProps} {...baseProps}
data={[makeModel({ input_cost: null as unknown as number, output_cost: null as unknown as number })]} data={[
makeModel({
input_cost: "0.60",
output_cost: "0.00",
output_cost_per_second: 0.015,
}),
]}
/>, />,
); );
expect(screen.queryByText(/^\$/)).not.toBeInTheDocument(); 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", () => { it("collapses extra access groups behind a +N more badge", () => {

View file

@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { getDisplayModelName } from "@/components/view_model/model_name_display"; import { getDisplayModelName } from "@/components/view_model/model_name_display";
import { copyToClipboard } from "@/utils/dataUtils"; import { copyToClipboard, formatPerSecondCost } from "@/utils/dataUtils";
export const MODEL_ID_COLUMN_ID = "model_info_id"; export const MODEL_ID_COLUMN_ID = "model_info_id";
export const MODEL_NAME_COLUMN_ID = "model_name"; export const MODEL_NAME_COLUMN_ID = "model_name";
@ -194,30 +194,33 @@ function CreatedByCell({ model }: { model: ModelData }) {
); );
} }
function CostsCell({ model }: { model: ModelData }) { function CostRow({ label, value }: { label: string; value: string }) {
const { input_cost: inputCost, output_cost: outputCost } = model; 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 <span className="text-sm text-muted-foreground">-</span>;
} }
return ( return (
<CellTooltip <CellTooltip
content="Cost per 1M tokens" content={hasPerSecond ? "Cost per 1M tokens; /s is cost per second of output" : "Cost per 1M tokens"}
trigger={ trigger={
<div className="flex flex-col gap-0.5 whitespace-nowrap"> <div className="flex flex-col gap-0.5 whitespace-nowrap">
{inputCost != null && ( {showInput && <CostRow label="IN" value={`$${inputCost}`} />}
<span className="flex items-baseline gap-1.5"> {showOutput && <CostRow label="OUT" value={`$${outputCost}`} />}
<span className="text-[10px] font-semibold tracking-wider text-muted-foreground">IN</span> {hasPerSecond && <CostRow label="OUT" value={formatPerSecondCost(perSecond)} />}
<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>
)}
</div> </div>
} }
/> />

View file

@ -101,6 +101,53 @@ describe("transformModelData", () => {
expect(result.data[0].output_cost).toBeNull(); 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", () => { it("should handle missing model_info", () => {
const rawData = { const rawData = {
data: [ data: [

View file

@ -1,76 +1,74 @@
/** import { LiteLLMParams, ModelData, ModelInfo, PerSecondCostTier } from "@/components/model_dashboard/types";
* 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 const PER_SECOND_TIER_KEY = /^output_cost_per_second_(.+)$/;
*/
export const transformModelData = (rawModelData: any, getProviderFromModel: (model: string) => string) => { 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 }] : [];
});
interface RawLitellmParams extends LiteLLMParams {
output_cost_per_second?: number;
}
interface RawModelInfo extends ModelInfo {
input_cost_per_token?: number | null;
output_cost_per_token?: number | null;
output_cost_per_second?: number;
max_tokens?: number;
max_input_tokens?: number;
[key: string]: unknown;
}
export interface RawModel {
model_name: string;
litellm_params: RawLitellmParams;
model_info: RawModelInfo;
[key: string]: unknown;
}
const costPerMillionTokens = (costPerToken: number | null | undefined): string | null =>
costPerToken == null ? null : (Number(costPerToken) * 1000000).toFixed(2);
const resolveProvider = (
litellmModelName: string | null | undefined,
customLlmProvider: string | null | undefined,
getProviderFromModel: (model: string) => string,
): string => {
if (!litellmModelName) return "-";
if (customLlmProvider) return customLlmProvider;
const splitModel = litellmModelName.split("/");
return splitModel.length === 1 ? getProviderFromModel(litellmModelName) : splitModel[0];
};
const transformModel = (rawModel: RawModel, getProviderFromModel: (model: string) => string): ModelData => {
const model: RawModel = JSON.parse(JSON.stringify(rawModel));
const litellmParams = model.litellm_params;
const modelInfo = model.model_info;
return {
...model,
provider: resolveProvider(litellmParams.model, litellmParams.custom_llm_provider, getProviderFromModel),
input_cost: costPerMillionTokens(modelInfo?.input_cost_per_token),
output_cost: costPerMillionTokens(modelInfo?.output_cost_per_token),
output_cost_per_second: litellmParams.output_cost_per_second ?? modelInfo?.output_cost_per_second ?? null,
output_cost_per_second_tiers: perSecondCostTiers(modelInfo),
litellm_model_name: litellmParams.model,
max_tokens: modelInfo?.max_tokens,
max_input_tokens: modelInfo?.max_input_tokens,
api_base: litellmParams.api_base,
cleanedLitellmParams: Object.fromEntries(
Object.entries(litellmParams).filter(([key]) => key !== "model" && key !== "api_base"),
),
};
};
export const transformModelData = (
rawModelData: { data?: RawModel[] | null } | null | undefined,
getProviderFromModel: (model: string) => string,
): { data: ModelData[] } => {
if (!rawModelData?.data) return { data: [] }; if (!rawModelData?.data) return { data: [] };
// Deep copy the data to avoid mutating the original return { data: rawModelData.data.map((rawModel) => transformModel(rawModel, getProviderFromModel)) };
const transformedData = JSON.parse(JSON.stringify(rawModelData.data));
for (let i = 0; i < transformedData.length; i++) {
let curr_model = transformedData[i];
let litellm_model_name = curr_model?.litellm_params?.model;
let custom_llm_provider = curr_model?.litellm_params?.custom_llm_provider;
let model_info = curr_model?.model_info;
let provider = "";
let input_cost: any = null;
let output_cost: any = null;
let max_tokens = "Undefined";
let max_input_tokens = "Undefined";
let cleanedLitellmParams = {};
// Check if litellm_model_name is null or undefined
if (litellm_model_name) {
// Split litellm_model_name based on "/"
let splitModel = litellm_model_name.split("/");
// Get the first element in the split
let firstElement = splitModel[0];
// If there is only one element, default provider to openai
provider = custom_llm_provider;
if (!provider) {
provider = splitModel.length === 1 ? getProviderFromModel(litellm_model_name) : firstElement;
}
} else {
// litellm_model_name is null or undefined, default provider to openai
provider = "-";
}
if (model_info) {
input_cost = model_info?.input_cost_per_token;
output_cost = model_info?.output_cost_per_token;
max_tokens = model_info?.max_tokens;
max_input_tokens = model_info?.max_input_tokens;
}
if (curr_model?.litellm_params) {
cleanedLitellmParams = Object.fromEntries(
Object.entries(curr_model?.litellm_params).filter(([key]) => key !== "model" && key !== "api_base"),
);
}
transformedData[i].provider = provider;
transformedData[i].input_cost = input_cost;
transformedData[i].output_cost = output_cost;
transformedData[i].litellm_model_name = litellm_model_name;
// Convert Cost in terms of Cost per 1M tokens
if (transformedData[i].input_cost != null) {
transformedData[i].input_cost = (Number(transformedData[i].input_cost) * 1000000).toFixed(2);
}
if (transformedData[i].output_cost != null) {
transformedData[i].output_cost = (Number(transformedData[i].output_cost) * 1000000).toFixed(2);
}
transformedData[i].max_tokens = max_tokens;
transformedData[i].max_input_tokens = max_input_tokens;
transformedData[i].api_base = curr_model?.litellm_params?.api_base;
transformedData[i].cleanedLitellmParams = cleanedLitellmParams;
}
return { data: transformedData };
}; };

View file

@ -275,7 +275,7 @@ const displayCost = (localModelData: any, field: TouchedPricingField): string =>
interface ModelInfoEditFormProps { interface ModelInfoEditFormProps {
localModelData: any; localModelData: any;
modelData: { model_info: { team_id?: string | null } & Record<string, unknown> }; modelData: { model_info: { team_id?: string | null } };
teamAlias: string | null; teamAlias: string | null;
accessToken: string | null; accessToken: string | null;
isEditing: boolean; isEditing: boolean;

View file

@ -1,3 +1,8 @@
export interface PerSecondCostTier {
resolution: string;
cost: number;
}
export interface ModelInfo { export interface ModelInfo {
id: string; id: string;
created_at: string; created_at: string;
@ -8,6 +13,7 @@ export interface ModelInfo {
access_groups: string[] | null; access_groups: string[] | null;
blocked?: boolean; blocked?: boolean;
team_public_model_name?: string; team_public_model_name?: string;
key?: string;
} }
export interface LiteLLMParams { export interface LiteLLMParams {
@ -25,10 +31,12 @@ export interface ModelData {
model_name: string; model_name: string;
provider: string; provider: string;
litellm_model_name: string; litellm_model_name: string;
input_cost: number; input_cost: string | null;
output_cost: number; output_cost: string | null;
max_tokens: number; output_cost_per_second?: number | null;
max_input_tokens: number; output_cost_per_second_tiers?: PerSecondCostTier[];
max_tokens?: number;
max_input_tokens?: number;
api_base?: string; api_base?: string;
litellm_params: LiteLLMParams; litellm_params: LiteLLMParams;
cleanedLitellmParams: Record<string, any>; cleanedLitellmParams: Record<string, any>;

View file

@ -432,6 +432,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 () => { it("should display edit settings button when user can edit model", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper }); render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
await waitFor(() => { await waitFor(() => {

View file

@ -41,6 +41,7 @@ import {
testConnectionRequest, testConnectionRequest,
} from "./networking"; } from "./networking";
import { Logo } from "@/components/molecules/logo/Logo"; import { Logo } from "@/components/molecules/logo/Logo";
import { ModelPricingSummary } from "@/components/molecules/models/ModelPricingSummary";
import UpdateModelCredentialsModal from "./update_model_credentials_modal"; import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import ModelInfoEditForm, { type ModelEditFormValues, type TouchedPricingField } from "./ModelInfoEditForm"; import ModelInfoEditForm, { type ModelEditFormValues, type TouchedPricingField } from "./ModelInfoEditForm";
import { Tag } from "./tag_management/types"; import { Tag } from "./tag_management/types";
@ -368,7 +369,7 @@ export default function ModelInfoView({
// Parse the model_info from the form values // Parse the model_info from the form values
let updatedModelInfo; let updatedModelInfo;
try { try {
updatedModelInfo = values.model_info ? JSON.parse(values.model_info) : modelData.model_info; updatedModelInfo = values.model_info ? JSON.parse(values.model_info) : modelData?.model_info;
// Update access_groups from the form // Update access_groups from the form
if (values.model_access_group) { if (values.model_access_group) {
updatedModelInfo = { updatedModelInfo = {
@ -662,10 +663,7 @@ export default function ModelInfoView({
</Card> </Card>
<Card className="block p-6"> <Card className="block p-6">
<p className="text-sm">Pricing</p> <p className="text-sm">Pricing</p>
<div className="mt-2"> <ModelPricingSummary model={modelData} />
<p className="text-sm">Input: ${modelData.input_cost}/1M tokens</p>
<p className="text-sm">Output: ${modelData.output_cost}/1M tokens</p>
</div>
</Card> </Card>
</div> </div>

View file

@ -0,0 +1,55 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ModelPricingSummary } from "./ModelPricingSummary";
const tokenPriced = { input_cost: "1.50", output_cost: "2.00" };
describe("ModelPricingSummary", () => {
it("shows per-million-token rates for a token priced model", () => {
render(<ModelPricingSummary model={tokenPriced} />);
expect(screen.getByText("Input: $1.50/1M tokens")).toBeInTheDocument();
expect(screen.getByText("Output: $2.00/1M tokens")).toBeInTheDocument();
});
it("hides $0.00 token rates and shows per-second tiers for a per-second priced model", () => {
render(
<ModelPricingSummary
model={{
input_cost: "0.00",
output_cost: "0.00",
output_cost_per_second: 0.1,
output_cost_per_second_tiers: [
{ resolution: "1080p", cost: 0.12 },
{ resolution: "4k", cost: 0.3 },
],
}}
/>,
);
expect(screen.getByText("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(/1M tokens/)).not.toBeInTheDocument();
});
it("keeps a positive token rate next to the per-second rate", () => {
render(
<ModelPricingSummary
model={{
input_cost: "0.60",
output_cost: "0.00",
output_cost_per_second: 0.015,
}}
/>,
);
expect(screen.getByText("Input: $0.60/1M tokens")).toBeInTheDocument();
expect(screen.getByText("Output: $0.015/s")).toBeInTheDocument();
expect(screen.queryByText("Output: $0.00/1M tokens")).not.toBeInTheDocument();
});
it("renders a dash when the model has no pricing at all", () => {
render(<ModelPricingSummary model={{ input_cost: null, output_cost: null }} />);
expect(screen.getByText("-")).toBeInTheDocument();
expect(screen.queryByText(/\$/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,31 @@
import { ModelData } from "@/components/model_dashboard/types";
import { formatPerSecondCost } from "@/utils/dataUtils";
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 = model.input_cost != null && (!hasPerSecond || Number(model.input_cost) > 0);
const showOutput = model.output_cost != null && (!hasPerSecond || Number(model.output_cost) > 0);
if (!showInput && !showOutput && !hasPerSecond) {
return <p className="mt-2 text-sm text-muted-foreground">-</p>;
}
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>
);
}

View file

@ -1,7 +1,13 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { copyToClipboard, formatNumberWithCommas, getSpendString, updateExistingKeys } from "./dataUtils"; import {
copyToClipboard,
formatNumberWithCommas,
formatPerSecondCost,
getSpendString,
updateExistingKeys,
} from "./dataUtils";
// Import the mocked module // Import the mocked module
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@ -115,6 +121,18 @@ describe("dataUtils", () => {
}); });
}); });
describe("formatPerSecondCost", () => {
it("should keep at least two decimals and append the per-second unit", () => {
expect(formatPerSecondCost(0.4)).toBe("$0.40/s");
expect(formatPerSecondCost(1)).toBe("$1.00/s");
});
it("should show sub-cent rates without rounding them to zero", () => {
expect(formatPerSecondCost(0.015)).toBe("$0.015/s");
expect(formatPerSecondCost(0.000025)).toBe("$0.000025/s");
});
});
describe("copyToClipboard", () => { describe("copyToClipboard", () => {
describe("when Clipboard API is available (HTTPS scenario)", () => { describe("when Clipboard API is available (HTTPS scenario)", () => {
beforeEach(() => { beforeEach(() => {

View file

@ -63,6 +63,9 @@ export const getSpendString = (value: number | null | undefined, decimals: numbe
return `$${formatted}`; return `$${formatted}`;
}; };
export const formatPerSecondCost = (cost: number): string =>
`$${cost.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 })}/s`;
export const copyToClipboard = async ( export const copyToClipboard = async (
text: string | null | undefined, text: string | null | undefined,
messageText: string = "Copied to clipboard", messageText: string = "Copied to clipboard",