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:
kerry-berri 2026-09-09 18:47:41 -07:00 committed by GitHub
commit a268c3d274
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 160 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
}