From 938a9203729ea18d7aec52120e7688781dfa0319 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Feb 2026 12:51:55 -0800 Subject: [PATCH 1/2] cost breakdown fix --- .../view_logs/CostBreakdownViewer.tsx | 94 +++-- .../LogDetailContent.test.tsx | 346 ++++++++++++++++++ .../LogDetailsDrawer/LogDetailContent.tsx | 44 ++- .../src/components/view_logs/index.tsx | 7 +- 4 files changed, 455 insertions(+), 36 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index 2b0e87ebe0..6c6b72d345 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -19,6 +19,8 @@ export interface CostBreakdown { interface CostBreakdownViewerProps { costBreakdown: CostBreakdown | null | undefined; totalSpend: number; + promptTokens?: number; + completionTokens?: number; } const formatCost = (cost: number | undefined): string => { @@ -34,30 +36,44 @@ const formatPercent = (percent: number | undefined): string => { export const CostBreakdownViewer: React.FC = ({ costBreakdown, totalSpend, + promptTokens, + completionTokens, }) => { - if (!costBreakdown) { + const isCached = totalSpend === 0; + const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined; + + // When cached, show if we have token counts; otherwise need costBreakdown with meaningful data + const hasCostBreakdown = costBreakdown?.input_cost !== undefined || costBreakdown?.output_cost !== undefined; + const hasMeaningfulData = + hasCostBreakdown || + hasTokenCounts || + (costBreakdown && + ((costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0) || + (costBreakdown.discount_amount !== undefined && costBreakdown.discount_amount !== 0) || + (costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0) || + (costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0) || + (costBreakdown.margin_total_amount !== undefined && costBreakdown.margin_total_amount !== 0))); + + if (!hasMeaningfulData && !(isCached && hasTokenCounts)) { return null; } const hasDiscount = - (costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0) || - (costBreakdown.discount_amount !== undefined && costBreakdown.discount_amount !== 0); - + costBreakdown && + ((costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0) || + (costBreakdown.discount_amount !== undefined && costBreakdown.discount_amount !== 0)); + const hasMargin = - (costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0) || - (costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0) || - (costBreakdown.margin_total_amount !== undefined && costBreakdown.margin_total_amount !== 0); + costBreakdown && + ((costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0) || + (costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0) || + (costBreakdown.margin_total_amount !== undefined && costBreakdown.margin_total_amount !== 0)); - // Don't show if there's no meaningful breakdown data - const hasMeaningfulData = - costBreakdown.input_cost !== undefined || - costBreakdown.output_cost !== undefined || - hasDiscount || - hasMargin; - - if (!hasMeaningfulData) { - return null; - } + // When cached, show $0 (authoritative total) instead of pre-cache costs from cost_breakdown + const inputCost = isCached ? 0 : costBreakdown?.input_cost; + const outputCost = isCached ? 0 : costBreakdown?.output_cost; + const originalCost = isCached ? 0 : costBreakdown?.original_cost; + const totalCost = isCached ? 0 : (costBreakdown?.total_cost ?? totalSpend); return (
@@ -71,7 +87,10 @@ export const CostBreakdownViewer: React.FC = ({

Cost Breakdown

Total: - {formatCost(totalSpend)} + + {formatCost(totalSpend)} + {isCached && " (Cached)"} +
), @@ -81,20 +100,34 @@ export const CostBreakdownViewer: React.FC = ({
Input Cost: - {formatCost(costBreakdown.input_cost)} + + {formatCost(inputCost)} + {promptTokens !== undefined && ( + + ({promptTokens.toLocaleString()} prompt tokens) + + )} +
Output Cost: - {formatCost(costBreakdown.output_cost)} + + {formatCost(outputCost)} + {completionTokens !== undefined && ( + + ({completionTokens.toLocaleString()} completion tokens) + + )} +
- {costBreakdown.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && ( + {costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
Tool Usage Cost: {formatCost(costBreakdown.tool_usage_cost)}
)} {/* Additional Costs (free-form) */} - {costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && ( + {costBreakdown?.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && ( <> {Object.entries(costBreakdown.additional_costs).map(([key, value]) => (
@@ -106,13 +139,15 @@ export const CostBreakdownViewer: React.FC = ({ )}
- {/* Subtotal / Original Cost */} -
-
- Original LLM Cost: - {formatCost(costBreakdown.original_cost)} + {/* Subtotal / Original Cost - hide when cached since it would be $0 */} + {!isCached && ( +
+
+ Original LLM Cost: + {formatCost(originalCost)} +
-
+ )} {/* Step 2: Adjustments (Discount & Margin) */} {(hasDiscount || hasMargin) && ( @@ -160,7 +195,8 @@ export const CostBreakdownViewer: React.FC = ({
Final Calculated Cost: - {formatCost(costBreakdown.total_cost ?? totalSpend)} + {formatCost(totalCost)} + {isCached && " (Cached)"}
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx new file mode 100644 index 0000000000..33de54991d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx @@ -0,0 +1,346 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { LogDetailContent } from "./LogDetailContent"; +import type { LogEntry } from "../columns"; + +vi.mock("../GuardrailViewer/GuardrailViewer", () => ({ + default: ({ data }: { data: unknown }) =>
{JSON.stringify(data)}
, +})); + +const createLogEntry = (overrides: Partial = {}): LogEntry => + ({ + request_id: "chatcmpl-test-id", + api_key: "api-key", + team_id: "team-id", + model: "gpt-4", + model_id: "gpt-4", + call_type: "chat", + spend: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + startTime: "2025-11-14T00:00:00Z", + endTime: "2025-11-14T00:00:01Z", + cache_hit: "miss", + duration: 1, + messages: [{ role: "user", content: "hello" }], + response: { choices: [{ message: { content: "hi" } }] }, + metadata: { status: "success" }, + request_tags: {}, + custom_llm_provider: "openai", + api_base: "https://api.example.com", + ...overrides, + }) as LogEntry; + +describe("LogDetailContent", () => { + it("should render the component successfully", () => { + render(); + + expect(screen.getByText("Request Details")).toBeInTheDocument(); + }); + + it("should display Request Details with model, provider, and call type", () => { + render( + , + ); + + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + expect(screen.getByText("anthropic")).toBeInTheDocument(); + expect(screen.getByText("completion")).toBeInTheDocument(); + }); + + it("should display error alert when request has failed", () => { + render( + , + ); + + expect(screen.getByText("Request Failed")).toBeInTheDocument(); + expect(screen.getByText("rate_limit")).toBeInTheDocument(); + expect(screen.getByText("Too many requests")).toBeInTheDocument(); + }); + + it("should display tags section when request_tags has entries", () => { + render( + , + ); + + expect(screen.getByText("Tags")).toBeInTheDocument(); + expect(screen.getByText("env: prod")).toBeInTheDocument(); + expect(screen.getByText("version: 1.0")).toBeInTheDocument(); + }); + + it("should not display tags section when request_tags is empty", () => { + render(); + + expect(screen.queryByText("Tags")).not.toBeInTheDocument(); + }); + + it("should display Metrics section with tokens and cost", () => { + render( + , + ); + + expect(screen.getByText("Metrics")).toBeInTheDocument(); + expect(screen.getAllByText("$0.00200000").length).toBeGreaterThanOrEqual(1); + }); + + it("should display ConfigInfoMessage when no messages, response, or error and not loading", () => { + render( + , + ); + + expect(screen.getByText("Request/Response Data Not Available")).toBeInTheDocument(); + }); + + it("should not display ConfigInfoMessage when isLoadingDetails is true even without data", () => { + render( + , + ); + + expect(screen.queryByText("Request/Response Data Not Available")).not.toBeInTheDocument(); + }); + + it("should call onOpenSettings when user clicks open settings in ConfigInfoMessage", async () => { + const onOpenSettings = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + const settingsButton = screen.getByRole("button", { name: /open the settings/i }); + await user.click(settingsButton); + + expect(onOpenSettings).toHaveBeenCalledTimes(1); + }); + + it("should display loading state when isLoadingDetails is true", () => { + render( + , + ); + + expect(screen.getByText("Loading request & response data...")).toBeInTheDocument(); + }); + + it("should display Request & Response section with Pretty and JSON view modes", () => { + render(); + + expect(screen.getByText("Request & Response")).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Pretty" })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "JSON" })).toBeInTheDocument(); + }); + + it("should display Request and Response tabs when JSON view is selected", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("JSON")); + + expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Response" })).toBeInTheDocument(); + }); + + it("should display response not available message when no response and Response tab is selected", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByText("JSON")); + await user.click(screen.getByRole("tab", { name: "Response" })); + + expect(screen.getByText("Response data not available")).toBeInTheDocument(); + }); + + it("should display Metadata section when metadata has keys", () => { + render( + , + ); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + }); + + it("should display IP address when requester_ip_address is present", () => { + render( + , + ); + + expect(screen.getByText("192.168.1.1")).toBeInTheDocument(); + }); + + it("should display guardrail label when guardrail data exists", () => { + render( + , + ); + + expect(screen.getByText("PII Filter")).toBeInTheDocument(); + expect(screen.getByText("2 masked")).toBeInTheDocument(); + }); + + it("should display cache hit information when cache_hit is true", () => { + render( + , + ); + + expect(screen.getByText("Cache Hit")).toBeInTheDocument(); + expect(screen.getByText("true")).toBeInTheDocument(); + expect(screen.getByText("Cache Read Tokens")).toBeInTheDocument(); + expect(screen.getByText("100")).toBeInTheDocument(); + }); + + it("should display LiteLLM Overhead when litellm_overhead_time_ms is in metadata", () => { + render( + , + ); + + expect(screen.getByText("LiteLLM Overhead")).toBeInTheDocument(); + expect(screen.getByText("42.50 ms")).toBeInTheDocument(); + }); + + it("should display start and end time in ISO format", () => { + render( + , + ); + + expect(screen.getByText("Start Time")).toBeInTheDocument(); + expect(screen.getByText("End Time")).toBeInTheDocument(); + const dateElements = screen.getAllByText((content) => content.includes("2025-11-14")); + expect(dateElements.length).toBeGreaterThanOrEqual(2); + }); + + it("should display Vector Store Requests when vector store data exists", () => { + render( + , + ); + + expect(screen.getByText("Vector Store Requests")).toBeInTheDocument(); + }); + + it("should display provider as dash when custom_llm_provider is absent", () => { + render( + , + ); + + const descriptions = screen.getByText("Provider").closest(".ant-descriptions-item"); + expect(descriptions).toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index 9081219d5b..fcc0c25daa 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -135,7 +135,12 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = {/* Cost Breakdown */} - + {/* Tools */} @@ -237,9 +242,17 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: (metadata?.additional_usage_values?.cache_read_input_tokens && metadata.additional_usage_values.cache_read_input_tokens > 0); + const cacheHitValue = String(logEntry.cache_hit ?? "None"); + const cacheHitColor = + cacheHitValue.toLowerCase() === "true" + ? "green" + : cacheHitValue.toLowerCase() === "false" + ? "red" + : "default"; + return (
- + - {logEntry.cache_hit || "None"} + {cacheHitValue} {metadata?.additional_usage_values?.cache_read_input_tokens > 0 && ( @@ -310,12 +323,31 @@ function RequestResponseSection({ return JSON.stringify(data, null, 2); }; - const totalSpend = logEntry.spend || 0; + const totalSpend = logEntry.spend ?? 0; const promptTokens = logEntry.prompt_tokens || 0; const completionTokens = logEntry.completion_tokens || 0; const totalTokens = promptTokens + completionTokens; - const inputCost = totalTokens > 0 ? (totalSpend * promptTokens) / totalTokens : 0; - const outputCost = totalTokens > 0 ? (totalSpend * completionTokens) / totalTokens : 0; + const costBreakdown = logEntry.metadata?.cost_breakdown; + const useCostBreakdown = + totalSpend > 0 && + costBreakdown?.input_cost !== undefined && + costBreakdown?.output_cost !== undefined; + const inputCost = + totalSpend === 0 + ? 0 + : useCostBreakdown + ? (costBreakdown!.input_cost ?? 0) + : totalTokens > 0 + ? (totalSpend * promptTokens) / totalTokens + : 0; + const outputCost = + totalSpend === 0 + ? 0 + : useCostBreakdown + ? (costBreakdown!.output_cost ?? 0) + : totalTokens > 0 + ? (totalSpend * completionTokens) / totalTokens + : 0; return (
diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 89a00fbb4c..d83b99b930 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -956,7 +956,12 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row; onO
{/* Cost Breakdown - Show if cost breakdown data is available */} - + {/* Configuration Info Message - Show when data is missing */} From b65cb646aa994d9fb496bfa15a7e9234b90f0a65 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Feb 2026 13:59:49 -0800 Subject: [PATCH 2/2] addressing comments --- .../view_logs/CostBreakdownViewer.tsx | 7 +++-- .../LogDetailsDrawer/LogDetailContent.tsx | 28 ++++++++----------- .../src/components/view_logs/index.tsx | 1 + 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index 6c6b72d345..087863e947 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -21,6 +21,7 @@ interface CostBreakdownViewerProps { totalSpend: number; promptTokens?: number; completionTokens?: number; + cacheHit?: string; } const formatCost = (cost: number | undefined): string => { @@ -38,11 +39,11 @@ export const CostBreakdownViewer: React.FC = ({ totalSpend, promptTokens, completionTokens, + cacheHit, }) => { - const isCached = totalSpend === 0; + const isCached = cacheHit?.toLowerCase() === "true"; const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined; - // When cached, show if we have token counts; otherwise need costBreakdown with meaningful data const hasCostBreakdown = costBreakdown?.input_cost !== undefined || costBreakdown?.output_cost !== undefined; const hasMeaningfulData = hasCostBreakdown || @@ -54,7 +55,7 @@ export const CostBreakdownViewer: React.FC = ({ (costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0) || (costBreakdown.margin_total_amount !== undefined && costBreakdown.margin_total_amount !== 0))); - if (!hasMeaningfulData && !(isCached && hasTokenCounts)) { + if (!hasMeaningfulData) { return null; } diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index fcc0c25daa..91233f2853 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -140,6 +140,7 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = totalSpend={logEntry.spend ?? 0} promptTokens={logEntry.prompt_tokens} completionTokens={logEntry.completion_tokens} + cacheHit={logEntry.cache_hit} /> {/* Tools */} @@ -329,25 +330,18 @@ function RequestResponseSection({ const totalTokens = promptTokens + completionTokens; const costBreakdown = logEntry.metadata?.cost_breakdown; const useCostBreakdown = - totalSpend > 0 && costBreakdown?.input_cost !== undefined && costBreakdown?.output_cost !== undefined; - const inputCost = - totalSpend === 0 - ? 0 - : useCostBreakdown - ? (costBreakdown!.input_cost ?? 0) - : totalTokens > 0 - ? (totalSpend * promptTokens) / totalTokens - : 0; - const outputCost = - totalSpend === 0 - ? 0 - : useCostBreakdown - ? (costBreakdown!.output_cost ?? 0) - : totalTokens > 0 - ? (totalSpend * completionTokens) / totalTokens - : 0; + const inputCost = useCostBreakdown + ? (costBreakdown!.input_cost ?? 0) + : totalTokens > 0 + ? (totalSpend * promptTokens) / totalTokens + : 0; + const outputCost = useCostBreakdown + ? (costBreakdown!.output_cost ?? 0) + : totalTokens > 0 + ? (totalSpend * completionTokens) / totalTokens + : 0; return (
diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index d83b99b930..12cafcc6f6 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -961,6 +961,7 @@ export function RequestViewer({ row, onOpenSettings }: { row: Row; onO totalSpend={row.original.spend ?? 0} promptTokens={row.original.prompt_tokens} completionTokens={row.original.completion_tokens} + cacheHit={row.original.cache_hit} /> {/* Configuration Info Message - Show when data is missing */}