diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 1a49bb6ddc1..daf12d11743 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -646,11 +646,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/components/AllModelsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.test.tsx index 726070c4bb6..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 @@ -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" }, @@ -175,13 +175,41 @@ describe("AllModelsTable", () => { expect(screen.getByText("$30")).toBeInTheDocument(); expect(screen.getByText("$60")).toBeInTheDocument(); + rerender(); + 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.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", () => { 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 c5bab598a8b..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 @@ -12,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"; @@ -194,30 +194,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 ( + + {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..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,76 +1,74 @@ -/** - * 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 - */ -export const transformModelData = (rawModelData: any, getProviderFromModel: (model: string) => string) => { +import { LiteLLMParams, ModelData, ModelInfo, 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 }] : []; + }); + +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: [] }; - // 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].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) => transformModel(rawModel, getProviderFromModel)) }; }; diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index 70b9f5598a0..d07e49e4712 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -275,7 +275,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 e58204995dd..f580e31a933 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; @@ -8,6 +13,7 @@ export interface ModelInfo { access_groups: string[] | null; blocked?: boolean; team_public_model_name?: string; + key?: string; } export interface LiteLLMParams { @@ -25,10 +31,12 @@ export interface ModelData { model_name: string; provider: string; litellm_model_name: string; - input_cost: number; - output_cost: number; - max_tokens: number; - max_input_tokens: 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; api_base?: string; litellm_params: LiteLLMParams; cleanedLitellmParams: Record; 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 2f5f3f9701f..6b66d0c0ffd 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -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(, { 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 38816e81b6d..77c9d700c69 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"; @@ -368,7 +369,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 = { @@ -662,10 +663,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.test.tsx b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx new file mode 100644 index 00000000000..921a824e671 --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.test.tsx @@ -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(); + 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 new file mode 100644 index 00000000000..10b37c6100c --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/models/ModelPricingSummary.tsx @@ -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

-

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

+ ))} +
+ ); +} 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",