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:
Kerry Lu 2026-09-09 18:19:52 -07:00
parent b8d7f68aeb
commit 3aa9267df2
6 changed files with 105 additions and 11 deletions

View file

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

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

View file

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

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

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