mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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:
commit
a9ee15372f
13 changed files with 325 additions and 110 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue