diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index 2b0e87ebe0..087863e947 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -19,6 +19,9 @@ export interface CostBreakdown { interface CostBreakdownViewerProps { costBreakdown: CostBreakdown | null | undefined; totalSpend: number; + promptTokens?: number; + completionTokens?: number; + cacheHit?: string; } const formatCost = (cost: number | undefined): string => { @@ -34,31 +37,45 @@ const formatPercent = (percent: number | undefined): string => { export const CostBreakdownViewer: React.FC = ({ costBreakdown, totalSpend, + promptTokens, + completionTokens, + cacheHit, }) => { - if (!costBreakdown) { - return null; - } + const isCached = cacheHit?.toLowerCase() === "true"; + const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined; - const hasDiscount = - (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); - - // Don't show if there's no meaningful breakdown data + const hasCostBreakdown = costBreakdown?.input_cost !== undefined || costBreakdown?.output_cost !== undefined; const hasMeaningfulData = - costBreakdown.input_cost !== undefined || - costBreakdown.output_cost !== undefined || - hasDiscount || - hasMargin; + 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) { return null; } + const hasDiscount = + costBreakdown && + ((costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0) || + (costBreakdown.discount_amount !== undefined && costBreakdown.discount_amount !== 0)); + + const hasMargin = + 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)); + + // 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 (
= ({

Cost Breakdown

Total: - {formatCost(totalSpend)} + + {formatCost(totalSpend)} + {isCached && " (Cached)"} +
), @@ -81,20 +101,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 +140,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 +196,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 be25eed8ec..8ff4f53bdd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -137,7 +137,13 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = {/* Cost Breakdown */} - + {/* Tools */} @@ -258,9 +264,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 && ( @@ -339,12 +353,24 @@ 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 = + costBreakdown?.input_cost !== undefined && + costBreakdown?.output_cost !== undefined; + 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 a3f40aff99..7f52d6b9da 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -975,7 +975,13 @@ 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 */}