From dfc74d3806786841735d73e09110aee6a47c1b96 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:48:50 +0000 Subject: [PATCH 1/5] 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> --- .../components/AllModelsTable.test.tsx | 33 +++++++++++++ .../components/ModelsTableColumns.tsx | 36 +++++++------- .../utils/modelDataTransformer.test.ts | 47 +++++++++++++++++++ .../utils/modelDataTransformer.ts | 16 +++++++ .../src/components/model_dashboard/types.ts | 7 +++ .../src/components/model_info_view.test.tsx | 31 ++++++++++++ .../src/components/model_info_view.tsx | 6 +-- .../molecules/models/ModelPricingSummary.tsx | 27 +++++++++++ 8 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index 8ba71e82d48..3663477dd5a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx @@ -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( + , + ); + expect(screen.getByText("$0.40/s")).toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + + rerender( + , + ); + 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( + {label} + {value} + + ); +} - 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 -; } return ( - {inputCost != null && ( - - IN - ${inputCost} - - )} - {outputCost != null && ( - - OUT - ${outputCost} - - )} + {showInput && } + {showOutput && } + {hasPerSecond && } } /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.test.ts index 42b76726922..29b017b8549 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.test.ts @@ -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: [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts index 963fba57507..438bbc379c9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts @@ -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 | 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 diff --git a/ui/litellm-dashboard/src/components/model_dashboard/types.ts b/ui/litellm-dashboard/src/components/model_dashboard/types.ts index e58204995dd..dd9a5b36058 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/types.ts +++ b/ui/litellm-dashboard/src/components/model_dashboard/types.ts @@ -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; diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 768183907db..b15e406b790 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -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(, { 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(, { wrapper }); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 35afcdb2985..49e890a2563 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -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({

Pricing

-
-

Input: ${modelData.input_cost}/1M tokens

-

Output: ${modelData.output_cost}/1M tokens

-
+
diff --git a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx new file mode 100644 index 00000000000..bf0c2b6806c --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx @@ -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 ( +
+ {showInput &&

Input: ${model.input_cost}/1M tokens

} + {showOutput &&

Output: ${model.output_cost}/1M tokens

} + {hasPerSecond &&

Output: {formatPerSecondCost(perSecond)}

} + {(model.output_cost_per_second_tiers ?? []).map(({ resolution, cost }) => ( +

+ Output ({resolution}): {formatPerSecondCost(cost)} +

+ ))} +
+ ); +} From a282e3c78ca5d8b9b3df32bce637f92255cb90a0 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 17 Sep 2026 16:24:55 -0700 Subject: [PATCH 2/5] refactor(ui): build transformed model rows without in-place mutation Greptile flagged the per-second fields for extending the transformer's mutate-in-a-loop pattern. Map each raw model to a new object instead so no field is assigned onto a shared reference, and drop the now unused prefer-const suppression for the file --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../utils/modelDataTransformer.ts | 135 ++++++++---------- 2 files changed, 60 insertions(+), 80 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 7de7373b20b..257f6de1a82 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -656,11 +656,6 @@ "count": 1 } }, - "src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": { - "prefer-const": { - "count": 6 - } - }, "src/app/(dashboard)/old-usage/_components/usage.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts index 438bbc379c9..5dce4cb8b65 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts @@ -11,82 +11,67 @@ export const perSecondCostTiers = (modelInfo: Record | null | u 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 - */ +interface RawLitellmParams { + model?: string; + custom_llm_provider?: string; + api_base?: string; + output_cost_per_second?: number; + [key: string]: unknown; +} + +interface RawModelInfo { + 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; +} + +interface RawModel { + litellm_params?: RawLitellmParams; + model_info?: RawModelInfo; + [key: string]: unknown; +} + +const costPerMillionTokens = (costPerToken: number | null | undefined) => + costPerToken == null ? costPerToken : (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) => { + 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: modelInfo ? costPerMillionTokens(modelInfo.input_cost_per_token) : null, + output_cost: modelInfo ? costPerMillionTokens(modelInfo.output_cost_per_token) : null, + 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 ? modelInfo.max_tokens : "Undefined", + max_input_tokens: modelInfo ? modelInfo.max_input_tokens : "Undefined", + api_base: litellmParams?.api_base, + cleanedLitellmParams: Object.fromEntries( + Object.entries(litellmParams ?? {}).filter(([key]) => key !== "model" && key !== "api_base"), + ), + }; +}; + export const transformModelData = (rawModelData: any, getProviderFromModel: (model: string) => string) => { if (!rawModelData?.data) return { data: [] }; - // Deep copy the data to avoid mutating the original - 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].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 - 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 }; + return { data: rawModelData.data.map((rawModel: RawModel) => transformModel(rawModel, getProviderFromModel)) }; }; From b5070408e7edd5211dadf89495db3f813178c905 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 18 Sep 2026 15:48:56 +0000 Subject: [PATCH 3/5] refactor(ui): move per-second cost formatter to dataUtils and type transformModelData input Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/ModelsTableColumns.tsx | 3 +- .../utils/modelDataTransformer.ts | 14 ++--- .../models/ModelPricingSummary.test.tsx | 57 +++++++++++++++++++ .../molecules/models/ModelPricingSummary.tsx | 10 +++- .../src/utils/dataUtils.test.ts | 20 ++++++- ui/litellm-dashboard/src/utils/dataUtils.ts | 3 + 6 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index a8af629b29f..cbc31747688 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -3,7 +3,6 @@ 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"; @@ -13,7 +12,7 @@ import { Button } from "@/components/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { Switch } from "@/components/ui/switch"; 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_NAME_COLUMN_ID = "model_name"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts index 5dce4cb8b65..ef76a506a18 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts @@ -8,9 +8,6 @@ export const perSecondCostTiers = (modelInfo: Record | null | u return resolution !== undefined && typeof value === "number" ? [{ resolution, cost: value }] : []; }); -export const formatPerSecondCost = (cost: number): string => - `$${cost.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 })}/s`; - interface RawLitellmParams { model?: string; custom_llm_provider?: string; @@ -50,8 +47,8 @@ const resolveProvider = ( const transformModel = (rawModel: RawModel, getProviderFromModel: (model: string) => string) => { const model: RawModel = JSON.parse(JSON.stringify(rawModel)); - const litellmParams = model?.litellm_params; - const modelInfo = model?.model_info; + const litellmParams = model.litellm_params; + const modelInfo = model.model_info; return { ...model, @@ -70,8 +67,11 @@ const transformModel = (rawModel: RawModel, getProviderFromModel: (model: string }; }; -export const transformModelData = (rawModelData: any, getProviderFromModel: (model: string) => string) => { +export const transformModelData = ( + rawModelData: { data?: RawModel[] | null } | null | undefined, + getProviderFromModel: (model: string) => string, +) => { if (!rawModelData?.data) return { data: [] }; - return { data: rawModelData.data.map((rawModel: RawModel) => transformModel(rawModel, getProviderFromModel)) }; + return { data: rawModelData.data.map((rawModel) => transformModel(rawModel, getProviderFromModel)) }; }; diff --git a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx new file mode 100644 index 00000000000..5832f4cd17b --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx @@ -0,0 +1,57 @@ +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" as unknown as number, output_cost: "2.00" as unknown as number }; + +describe("ModelPricingSummary", () => { + it("shows per-million-token rates for a token priced model", () => { + render(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText("-")).toBeInTheDocument(); + expect(screen.queryByText(/\$/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx index bf0c2b6806c..10b37c6100c 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx @@ -1,5 +1,5 @@ -import { formatPerSecondCost } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer"; import { ModelData } from "@/components/model_dashboard/types"; +import { formatPerSecondCost } from "@/utils/dataUtils"; type PricingFields = Pick< ModelData, @@ -9,8 +9,12 @@ type PricingFields = Pick< 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; + 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

-

; + } return (
diff --git a/ui/litellm-dashboard/src/utils/dataUtils.test.ts b/ui/litellm-dashboard/src/utils/dataUtils.test.ts index f696780bc0a..af249ca601a 100644 --- a/ui/litellm-dashboard/src/utils/dataUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/dataUtils.test.ts @@ -1,7 +1,13 @@ // @vitest-environment jsdom 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 { 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("when Clipboard API is available (HTTPS scenario)", () => { beforeEach(() => { diff --git a/ui/litellm-dashboard/src/utils/dataUtils.ts b/ui/litellm-dashboard/src/utils/dataUtils.ts index 514d13bf9f4..8908041a626 100644 --- a/ui/litellm-dashboard/src/utils/dataUtils.ts +++ b/ui/litellm-dashboard/src/utils/dataUtils.ts @@ -63,6 +63,9 @@ export const getSpendString = (value: number | null | undefined, decimals: numbe return `$${formatted}`; }; +export const formatPerSecondCost = (cost: number): string => + `$${cost.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 6 })}/s`; + export const copyToClipboard = async ( text: string | null | undefined, messageText: string = "Copied to clipboard", From d197ca20fa1dcf98511d85898f9c691b42621b83 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 18 Sep 2026 16:13:28 +0000 Subject: [PATCH 4/5] fix(ui): type transformModelData output as ModelData so the dashboard build typechecks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/AllModelsTable.test.tsx | 14 +++--- .../utils/modelDataTransformer.ts | 43 +++++++++---------- .../src/components/ModelInfoEditForm.tsx | 2 +- .../src/components/model_dashboard/types.ts | 9 ++-- .../src/components/model_info_view.tsx | 2 +- .../models/ModelPricingSummary.test.tsx | 12 +++--- 6 files changed, 40 insertions(+), 42 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index 1f18d9cb4e6..3c054831d99 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx @@ -11,8 +11,8 @@ const makeModel = (overrides: Partial = {}): ModelData => model_name: "gpt-4-public", litellm_model_name: "openai/gpt-4", provider: "openai", - input_cost: 30 as unknown as number, - output_cost: 60 as unknown as number, + input_cost: "30", + output_cost: "60", max_tokens: 8192, max_input_tokens: 8192, litellm_params: { model: "openai/gpt-4" }, @@ -178,7 +178,7 @@ describe("AllModelsTable", () => { rerender( , ); expect(screen.queryByText(/^\$/)).not.toBeInTheDocument(); @@ -190,8 +190,8 @@ describe("AllModelsTable", () => { {...baseProps} data={[ makeModel({ - input_cost: "0.00" as unknown as number, - output_cost: "0.00" as unknown as number, + input_cost: "0.00", + output_cost: "0.00", output_cost_per_second: 0.4, }), ]} @@ -205,8 +205,8 @@ describe("AllModelsTable", () => { {...baseProps} data={[ makeModel({ - input_cost: "0.60" as unknown as number, - output_cost: "0.00" as unknown as number, + input_cost: "0.60", + output_cost: "0.00", output_cost_per_second: 0.015, }), ]} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts index ef76a506a18..d33b9f91e2f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts @@ -1,4 +1,4 @@ -import { PerSecondCostTier } from "@/components/model_dashboard/types"; +import { LiteLLMParams, ModelData, ModelInfo, PerSecondCostTier } from "@/components/model_dashboard/types"; const PER_SECOND_TIER_KEY = /^output_cost_per_second_(.+)$/; @@ -8,15 +8,11 @@ export const perSecondCostTiers = (modelInfo: Record | null | u return resolution !== undefined && typeof value === "number" ? [{ resolution, cost: value }] : []; }); -interface RawLitellmParams { - model?: string; - custom_llm_provider?: string; - api_base?: string; +interface RawLitellmParams extends LiteLLMParams { output_cost_per_second?: number; - [key: string]: unknown; } -interface RawModelInfo { +interface RawModelInfo extends ModelInfo { input_cost_per_token?: number | null; output_cost_per_token?: number | null; output_cost_per_second?: number; @@ -25,14 +21,15 @@ interface RawModelInfo { [key: string]: unknown; } -interface RawModel { - litellm_params?: RawLitellmParams; - model_info?: RawModelInfo; +export interface RawModel { + model_name: string; + litellm_params: RawLitellmParams; + model_info: RawModelInfo; [key: string]: unknown; } -const costPerMillionTokens = (costPerToken: number | null | undefined) => - costPerToken == null ? costPerToken : (Number(costPerToken) * 1000000).toFixed(2); +const costPerMillionTokens = (costPerToken: number | null | undefined): string | null => + costPerToken == null ? null : (Number(costPerToken) * 1000000).toFixed(2); const resolveProvider = ( litellmModelName: string | null | undefined, @@ -45,24 +42,24 @@ const resolveProvider = ( return splitModel.length === 1 ? getProviderFromModel(litellmModelName) : splitModel[0]; }; -const transformModel = (rawModel: RawModel, getProviderFromModel: (model: string) => string) => { +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: modelInfo ? costPerMillionTokens(modelInfo.input_cost_per_token) : null, - output_cost: modelInfo ? costPerMillionTokens(modelInfo.output_cost_per_token) : null, - output_cost_per_second: litellmParams?.output_cost_per_second ?? modelInfo?.output_cost_per_second ?? null, + 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 ? modelInfo.max_tokens : "Undefined", - max_input_tokens: modelInfo ? modelInfo.max_input_tokens : "Undefined", - api_base: litellmParams?.api_base, + 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"), + Object.entries(litellmParams).filter(([key]) => key !== "model" && key !== "api_base"), ), }; }; @@ -70,7 +67,7 @@ const transformModel = (rawModel: RawModel, getProviderFromModel: (model: string export const transformModelData = ( rawModelData: { data?: RawModel[] | null } | null | undefined, getProviderFromModel: (model: string) => string, -) => { +): { data: ModelData[] } => { if (!rawModelData?.data) return { data: [] }; return { data: rawModelData.data.map((rawModel) => transformModel(rawModel, getProviderFromModel)) }; diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index fb4d90b5ea2..4f0af75ef75 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -270,7 +270,7 @@ const displayCost = (localModelData: any, field: TouchedPricingField): string => interface ModelInfoEditFormProps { localModelData: any; - modelData: { model_info: { team_id?: string | null } & Record }; + modelData: { model_info: { team_id?: string | null } }; teamAlias: string | null; accessToken: string | null; isEditing: boolean; diff --git a/ui/litellm-dashboard/src/components/model_dashboard/types.ts b/ui/litellm-dashboard/src/components/model_dashboard/types.ts index dd9a5b36058..f580e31a933 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/types.ts +++ b/ui/litellm-dashboard/src/components/model_dashboard/types.ts @@ -13,6 +13,7 @@ export interface ModelInfo { access_groups: string[] | null; blocked?: boolean; team_public_model_name?: string; + key?: string; } export interface LiteLLMParams { @@ -30,12 +31,12 @@ export interface ModelData { model_name: string; provider: string; litellm_model_name: string; - input_cost: number; - output_cost: number; + input_cost: string | null; + output_cost: string | null; output_cost_per_second?: number | null; output_cost_per_second_tiers?: PerSecondCostTier[]; - max_tokens: number; - max_input_tokens: number; + max_tokens?: number; + max_input_tokens?: number; api_base?: string; litellm_params: LiteLLMParams; cleanedLitellmParams: Record; diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index f4b6744b42e..ee3ac2ecbcb 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -365,7 +365,7 @@ export default function ModelInfoView({ // Parse the model_info from the form values let updatedModelInfo; 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 if (values.model_access_group) { updatedModelInfo = { diff --git a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx index 5832f4cd17b..7cccea2a540 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { ModelPricingSummary } from "./ModelPricingSummary"; -const tokenPriced = { input_cost: "1.50" as unknown as number, output_cost: "2.00" as unknown as number }; +const tokenPriced = { input_cost: "1.50", output_cost: "2.00" }; describe("ModelPricingSummary", () => { it("shows per-million-token rates for a token priced model", () => { @@ -16,8 +16,8 @@ describe("ModelPricingSummary", () => { render( { render( , @@ -49,7 +49,7 @@ describe("ModelPricingSummary", () => { it("renders a dash when the model has no pricing at all", () => { render( - , + , ); expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.queryByText(/\$/)).not.toBeInTheDocument(); From 4f86035a79c27d833c6e263f42574d4cd800b285 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 18 Sep 2026 16:16:28 +0000 Subject: [PATCH 5/5] style(ui): format pricing test fixtures with prettier Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/AllModelsTable.test.tsx | 7 +------ .../molecules/models/ModelPricingSummary.test.tsx | 4 +--- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index 3c054831d99..cc0169d745c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx @@ -175,12 +175,7 @@ describe("AllModelsTable", () => { expect(screen.getByText("$30")).toBeInTheDocument(); expect(screen.getByText("$60")).toBeInTheDocument(); - rerender( - , - ); + rerender(); expect(screen.queryByText(/^\$/)).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx index 7cccea2a540..921a824e671 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx @@ -48,9 +48,7 @@ describe("ModelPricingSummary", () => { }); it("renders a dash when the model has no pricing at all", () => { - render( - , - ); + render(); expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.queryByText(/\$/)).not.toBeInTheDocument(); });