mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(ui): drop blank streamed costs instead of reading them as zero
Number("") and Number(" ") both return 0, which passed the finite check, so a
provider reporting an empty cost got a fabricated $0.000000 metric instead of
having the unusable value omitted.
Both ingestion sites carried the same inline parsing, so this pulls it into one
parseUsageCost helper that keeps finite numbers and non-blank numeric strings and
drops everything else, including booleans, arrays and breakdown objects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNw8WvkvCSeTcE5qvergu3
This commit is contained in:
parent
b8d7f68aeb
commit
3aa9267df2
6 changed files with 105 additions and 11 deletions
|
|
@ -472,6 +472,12 @@ describe("chat_completion prompt cache usage", () => {
|
|||
|
||||
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,12 +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) {
|
||||
const parsedCost = Number(chunkWithUsage.usage.cost);
|
||||
if (Number.isFinite(parsedCost)) {
|
||||
usageData.cost = parsedCost;
|
||||
}
|
||||
const parsedCost = parseUsageCost(chunkWithUsage.usage.cost);
|
||||
if (parsedCost !== undefined) {
|
||||
usageData.cost = parsedCost;
|
||||
}
|
||||
|
||||
onUsageData(usageData);
|
||||
|
|
|
|||
|
|
@ -260,6 +260,35 @@ describe("responses_api", () => {
|
|||
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,11 +312,9 @@ export async function makeOpenAIResponsesRequest(
|
|||
usageData.reasoningTokens = reasoningTokens;
|
||||
}
|
||||
|
||||
if (usage.cost !== undefined && usage.cost !== null) {
|
||||
const parsedCost = Number(usage.cost);
|
||||
if (Number.isFinite(parsedCost)) {
|
||||
usageData.cost = parsedCost;
|
||||
}
|
||||
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