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:
Devin AI 2026-09-02 11:48:50 +00:00
parent 31ca4ddf32
commit dfc74d3806
8 changed files with 183 additions and 20 deletions

View file

@ -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

View file

@ -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>
}
/>

View file

@ -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: [

View file

@ -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

View file

@ -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;

View file

@ -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(() => {

View file

@ -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>

View file

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