mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge pull request #40257 from BerriAI/litellm_playground_null_cost_crash
fix(ui): guard playground cost metric against null and NaN
This commit is contained in:
commit
a268c3d274
8 changed files with 160 additions and 6 deletions
|
|
@ -51,4 +51,23 @@ describe("ResponseMetrics prompt cache chips", () => {
|
|||
|
||||
expect(screen.queryByText(/Response Cache/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the Cost chip when a persisted cost is null", () => {
|
||||
render(<ResponseMetrics usage={{ promptTokens: 1, cost: null as unknown as number }} />);
|
||||
|
||||
expect(screen.queryByText(/Cost:/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("In: 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the Cost chip for NaN", () => {
|
||||
render(<ResponseMetrics usage={{ ...baseUsage, cost: Number.NaN }} />);
|
||||
|
||||
expect(screen.queryByText(/Cost:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Cost chip for a finite cost", () => {
|
||||
render(<ResponseMetrics usage={{ ...baseUsage, cost: 0.000063 }} />);
|
||||
|
||||
expect(screen.getByText("Cost: $0.000063")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, tot
|
|||
/>
|
||||
)}
|
||||
|
||||
{usage?.cost !== undefined && (
|
||||
{typeof usage?.cost === "number" && Number.isFinite(usage.cost) && (
|
||||
<MetricItem
|
||||
label="Cost"
|
||||
tooltip="Cost"
|
||||
|
|
|
|||
|
|
@ -468,6 +468,18 @@ describe("chat_completion prompt cache usage", () => {
|
|||
expect(usageData).not.toHaveProperty("cacheReadTokens");
|
||||
expect(usageData).not.toHaveProperty("cacheCreationTokens");
|
||||
});
|
||||
|
||||
it("omits cost when the provider reports a non-numeric value", async () => {
|
||||
const usageData = await captureUsage({ cost: "not-a-number" });
|
||||
|
||||
expect(usageData).toEqual(expect.not.objectContaining({ cost: expect.anything() }));
|
||||
});
|
||||
|
||||
it("omits cost when the provider reports a blank value", async () => {
|
||||
const usageData = await captureUsage({ cost: " " });
|
||||
|
||||
expect(usageData).toEqual(expect.not.objectContaining({ cost: expect.anything() }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat_completion response cache", () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { VectorStoreSearchResponse } from "../chat_ui/types";
|
|||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { MCPServer, MCPToolset, type MCPEvent } from "@/components/mcp_tools/types";
|
||||
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
|
||||
import { parseUsageCost } from "./usage_cost";
|
||||
|
||||
const completionAsSingleChunk = (completion: ChatCompletion): ChatCompletionChunk =>
|
||||
({
|
||||
|
|
@ -243,9 +244,9 @@ export async function makeOpenAIChatCompletionRequest(
|
|||
usageData.reasoningTokens = chunkWithUsage.usage.completion_tokens_details.reasoning_tokens;
|
||||
}
|
||||
|
||||
// Extract cost from usage object if available
|
||||
if (chunkWithUsage.usage.cost !== undefined && chunkWithUsage.usage.cost !== null) {
|
||||
usageData.cost = parseFloat(chunkWithUsage.usage.cost);
|
||||
const parsedCost = parseUsageCost(chunkWithUsage.usage.cost);
|
||||
if (parsedCost !== undefined) {
|
||||
usageData.cost = parsedCost;
|
||||
}
|
||||
|
||||
onUsageData(usageData);
|
||||
|
|
|
|||
|
|
@ -233,6 +233,64 @@ describe("responses_api", () => {
|
|||
expect(onUsageData).toHaveBeenCalledWith(expect.not.objectContaining({ cost: expect.anything() }), "");
|
||||
});
|
||||
|
||||
it("should omit cost when the proxy reports a non-numeric cost", async () => {
|
||||
async function* streamWithNonNumericCost() {
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_non_numeric_cost",
|
||||
usage: { output_tokens: 12, input_tokens: 12, total_tokens: 24, cost: "not-a-number" },
|
||||
},
|
||||
};
|
||||
}
|
||||
mockResponsesCreate.mockResolvedValueOnce(streamWithNonNumericCost());
|
||||
|
||||
const onUsageData = vi.fn();
|
||||
|
||||
await makeOpenAIResponsesRequest(
|
||||
messages,
|
||||
mockUpdateTextUI,
|
||||
"gpt-4",
|
||||
"test-token",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
onUsageData,
|
||||
);
|
||||
|
||||
expect(onUsageData).toHaveBeenCalledWith(expect.not.objectContaining({ cost: expect.anything() }), "");
|
||||
});
|
||||
|
||||
it("should omit cost when the proxy reports a blank cost", async () => {
|
||||
async function* streamWithBlankCost() {
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_blank_cost",
|
||||
usage: { output_tokens: 12, input_tokens: 12, total_tokens: 24, cost: " " },
|
||||
},
|
||||
};
|
||||
}
|
||||
mockResponsesCreate.mockResolvedValueOnce(streamWithBlankCost());
|
||||
|
||||
const onUsageData = vi.fn();
|
||||
|
||||
await makeOpenAIResponsesRequest(
|
||||
messages,
|
||||
mockUpdateTextUI,
|
||||
"gpt-4",
|
||||
"test-token",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
onUsageData,
|
||||
);
|
||||
|
||||
expect(onUsageData).toHaveBeenCalledWith(expect.not.objectContaining({ cost: expect.anything() }), "");
|
||||
});
|
||||
|
||||
it("should replay MCP output items as events for a non-streaming response", async () => {
|
||||
mockResponsesCreate.mockReturnValueOnce(
|
||||
nonStreamingResponse({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { TokenUsage } from "../chat_ui/ResponseMetrics";
|
|||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
|
||||
import { parseUsageCost } from "./usage_cost";
|
||||
import type { MCPEvent } from "@/components/mcp_tools/types";
|
||||
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
|
||||
import {
|
||||
|
|
@ -311,8 +312,9 @@ export async function makeOpenAIResponsesRequest(
|
|||
usageData.reasoningTokens = reasoningTokens;
|
||||
}
|
||||
|
||||
if (usage.cost !== undefined && usage.cost !== null) {
|
||||
usageData.cost = Number(usage.cost);
|
||||
const parsedCost = parseUsageCost(usage.cost);
|
||||
if (parsedCost !== undefined) {
|
||||
usageData.cost = parsedCost;
|
||||
}
|
||||
|
||||
onUsageData(usageData, mcpToolUsed);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseUsageCost } from "./usage_cost";
|
||||
|
||||
describe("parseUsageCost", () => {
|
||||
it("keeps finite numbers, including zero", () => {
|
||||
expect(parseUsageCost(0)).toBe(0);
|
||||
expect(parseUsageCost(0.000063)).toBe(0.000063);
|
||||
});
|
||||
|
||||
it("keeps numeric strings", () => {
|
||||
expect(parseUsageCost("0.00019")).toBe(0.00019);
|
||||
expect(parseUsageCost(" 0.00019 ")).toBe(0.00019);
|
||||
});
|
||||
|
||||
it("drops blank strings instead of fabricating a zero cost", () => {
|
||||
expect(parseUsageCost("")).toBeUndefined();
|
||||
expect(parseUsageCost(" ")).toBeUndefined();
|
||||
expect(parseUsageCost("\t\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops strings with a numeric prefix instead of truncating them", () => {
|
||||
expect(parseUsageCost("1oops")).toBeUndefined();
|
||||
expect(parseUsageCost("0.5 USD")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-finite numbers", () => {
|
||||
expect(parseUsageCost(Number.NaN)).toBeUndefined();
|
||||
expect(parseUsageCost(Number.POSITIVE_INFINITY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops values that are not numbers or strings", () => {
|
||||
expect(parseUsageCost(null)).toBeUndefined();
|
||||
expect(parseUsageCost(undefined)).toBeUndefined();
|
||||
expect(parseUsageCost(true)).toBeUndefined();
|
||||
expect(parseUsageCost([])).toBeUndefined();
|
||||
expect(parseUsageCost(["0.5"])).toBeUndefined();
|
||||
expect(parseUsageCost({ total_cost: 0.5 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
23
ui/litellm-dashboard/src/components/llm_calls/usage_cost.ts
Normal file
23
ui/litellm-dashboard/src/components/llm_calls/usage_cost.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Providers and upstream gateways report `usage.cost` unvalidated: it arrives as a number, a numeric
|
||||
* string, an empty string, or something non-numeric. A cost that does not resolve to a finite number
|
||||
* must be dropped rather than coerced, because `NaN` survives `JSON.stringify` as `null` and crashes
|
||||
* the metrics row on the next load.
|
||||
*/
|
||||
export function parseUsageCost(rawCost: unknown): number | undefined {
|
||||
if (typeof rawCost === "number") {
|
||||
return Number.isFinite(rawCost) ? rawCost : undefined;
|
||||
}
|
||||
|
||||
if (typeof rawCost !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = rawCost.trim();
|
||||
if (trimmed === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue