diff --git a/litellm/integrations/prometheus_services.py b/litellm/integrations/prometheus_services.py index a5f2f0b5c72..55ce758ece6 100644 --- a/litellm/integrations/prometheus_services.py +++ b/litellm/integrations/prometheus_services.py @@ -105,6 +105,11 @@ class PrometheusServicesLogger: return metrics def is_metric_registered(self, metric_name) -> bool: + # Use _names_to_collectors (O(1)) instead of REGISTRY.collect() (O(n)) to avoid + # perf regression when a new Router is created per request (e.g. router_settings in DB). + names_to_collectors = getattr(self.REGISTRY, "_names_to_collectors", None) + if names_to_collectors is not None: + return metric_name in names_to_collectors for metric in self.REGISTRY.collect(): if metric_name == metric.name: return True diff --git a/tests/test_litellm/integrations/test_prometheus_services.py b/tests/test_litellm/integrations/test_prometheus_services.py index b627d31fda0..ff80d7d9f8b 100644 --- a/tests/test_litellm/integrations/test_prometheus_services.py +++ b/tests/test_litellm/integrations/test_prometheus_services.py @@ -1,6 +1,7 @@ import json import os import sys +import time from unittest.mock import AsyncMock, patch import pytest @@ -17,6 +18,63 @@ sys.path.insert( ) # Adds the parent directory to the system path +def test_is_metric_registered_does_not_use_registry_collect(): + """is_metric_registered() must use _names_to_collectors, not REGISTRY.collect() (perf; #19921).""" + from prometheus_client import CollectorRegistry, Counter, Histogram + + registry = CollectorRegistry() + for i in range(80): + Counter( + f"litellm_service_{i}_total_requests", + "Total requests", + labelnames=["service"], + registry=registry, + ) + Histogram( + f"litellm_service_{i}_latency", + "Latency", + labelnames=["service"], + registry=registry, + ) + + pl = PrometheusServicesLogger() + pl.REGISTRY = registry + + original_collect = registry.collect + collect_called = [] + + def track_collect(*args, **kwargs): + collect_called.append(1) + return original_collect(*args, **kwargs) + + registry.collect = track_collect + + n_calls = 30 * 2 + start = time.perf_counter() + for _ in range(30): + pl.is_metric_registered("litellm_service_0_latency") + pl.is_metric_registered("litellm_service_79_total_requests") + elapsed_s = time.perf_counter() - start + elapsed_ms = elapsed_s * 1000 + per_call_us = (elapsed_s / n_calls) * 1_000_000 if n_calls else 0 + n_collect = len(collect_called) + + path = "slow (REGISTRY.collect)" if n_collect else "fast (_names_to_collectors)" + print( + f"\n is_metric_registered: {elapsed_ms:.2f} ms total | " + f"{per_call_us:.1f} µs/call | {n_calls} calls | {n_collect} collect() | {path}\n" + ) + + assert n_collect == 0, ( + f"is_metric_registered() must not use REGISTRY.collect() when _names_to_collectors " + f"is available. Latency: {elapsed_ms:.2f} ms, {per_call_us:.1f} µs/call, {n_calls} calls, " + f"collect() called {n_collect} times." + ) + assert elapsed_s < 0.05, ( + f"is_metric_registered() took {elapsed_ms:.2f} ms for {n_calls} calls; expected <50 ms." + ) + + def test_create_gauge_new(): """Test creating a new gauge""" pl = PrometheusServicesLogger() diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index c56e2d3af50..01cbd74c5a8 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Accordion, AccordionHeader, AccordionBody } from "@tremor/react"; +import { Collapse } from "antd"; import { formatNumberWithCommas } from "@/utils/dataUtils"; export interface CostBreakdown { @@ -7,7 +7,6 @@ export interface CostBreakdown { output_cost?: number; total_cost?: number; tool_usage_cost?: number; - additional_costs?: Record; original_cost?: number; discount_percent?: number; discount_amount?: number; @@ -61,18 +60,22 @@ export const CostBreakdownViewer: React.FC = ({ return (
- - -
-

Cost Breakdown

-
- Total: - {formatCost(totalSpend)} -
-
-
- -
+ +

Cost Breakdown

+
+ Total: + {formatCost(totalSpend)} +
+
+ ), + children: ( +
{/* Step 1: Base Token Costs */}
@@ -89,17 +92,6 @@ export const CostBreakdownViewer: React.FC = ({ {formatCost(costBreakdown.tool_usage_cost)}
)} - {/* Additional Costs (free-form) */} - {costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && ( - <> - {Object.entries(costBreakdown.additional_costs).map(([key, value]) => ( -
- {key}: - {formatCost(value)} -
- ))} - - )}
{/* Subtotal / Original Cost */} @@ -161,8 +153,10 @@ export const CostBreakdownViewer: React.FC = ({
- - + ), + }, + ]} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx index 2dc3bdef97d..95120f60570 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders, screen } from "../../../../tests/test-utils"; +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; import { makeBedrockResponse, makeEntity, @@ -62,20 +62,26 @@ describe("GuardrailViewer", () => { it("toggles main section open/closed and chevron rotation class", async () => { const user = userEvent.setup(); const data = makeGuardrailInformation(); - renderWithProviders(); + const { container } = renderWithProviders(); - const header = screen.getByText("Guardrail Information").closest("div")!; - // Initially expanded - expect(screen.getByText("Click to collapse")).toBeInTheDocument(); + const header = screen.getByText("Guardrail Information").closest(".ant-collapse-header")!; + // Initially expanded (content is visible) + expect(screen.getByText("Masked Entity Summary")).toBeInTheDocument(); + // Click to collapse await user.click(header); - expect(screen.getByText("Click to expand")).toBeInTheDocument(); - // Details gone - expect(screen.queryByText("Masked Entity Summary")).not.toBeInTheDocument(); + // Wait for collapse animation and content to be hidden + await waitFor(() => { + const contentBox = container.querySelector(".ant-collapse-content-box"); + expect(contentBox).not.toBeVisible(); + }); // Click to expand again await user.click(header); - expect(screen.getByText("Click to collapse")).toBeInTheDocument(); + // Wait for expand animation + await waitFor(() => { + expect(screen.getByText("Masked Entity Summary")).toBeVisible(); + }); }); it("defaults to presidio provider when guardrail_provider is undefined", async () => { diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx index b25545200cd..ed2198ba859 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Tooltip } from "antd"; +import { Tooltip, Collapse } from "antd"; import PresidioDetectedEntities from "./PresidioDetectedEntities"; import BedrockGuardrailDetails, { BedrockGuardrailResponse, @@ -207,8 +207,6 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => { ? [data] : []; - const [sectionExpanded, setSectionExpanded] = useState(true); - const primaryName = guardrailEntries.length === 1 ? guardrailEntries[0].guardrail_name : `${guardrailEntries.length} guardrails`; const statuses = Array.from(new Set(guardrailEntries.map((entry) => entry.guardrail_status))); @@ -231,55 +229,51 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => { } return ( -
-
setSectionExpanded(!sectionExpanded)} - > -
- - - -

Guardrail Information

+
+ +

Guardrail Information

- - - {aggregatedStatus} - - + + + {aggregatedStatus} + + - {primaryName} + {primaryName} - {totalMaskedEntities > 0 && ( - - {totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"} - - )} -
- {sectionExpanded ? "Click to collapse" : "Click to expand"} -
- - {sectionExpanded && ( -
- {guardrailEntries.map((entry, index) => ( - - ))} -
- )} + {totalMaskedEntities > 0 && ( + + {totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"} + + )} +
+ ), + children: ( +
+ {guardrailEntries.map((entry, index) => ( + + ))} +
+ ), + }, + ]} + />
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx new file mode 100644 index 00000000000..2f036a2a34a --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx @@ -0,0 +1,124 @@ +/** + * Formatted view of tool definition with parameters table and call data + */ + +import { Typography, Table } from "antd"; +import { ParsedTool, ParameterRow } from "./types"; + +const { Text } = Typography; + +interface FormattedToolViewProps { + tool: ParsedTool; +} + +export function FormattedToolView({ tool }: FormattedToolViewProps) { + // Parse parameters for table display + const parameterRows: ParameterRow[] = Object.entries( + tool.parameters?.properties || {} + ).map(([name, schema]: [string, any]) => ({ + key: name, + name: name, + type: schema.type || "any", + description: schema.description || "-", + required: tool.parameters?.required?.includes(name) || false, + })); + + const columns = [ + { + title: "Parameter", + dataIndex: "name", + key: "name", + render: (name: string, record: ParameterRow) => ( + + {name} + {record.required && *} + + ), + }, + { + title: "Type", + dataIndex: "type", + key: "type", + render: (type: string) => ( + + {type} + + ), + }, + { + title: "Description", + dataIndex: "description", + key: "description", + render: (desc: string) => {desc}, + }, + ]; + + return ( +
+ {/* Description */} + {tool.description && ( +
+ {tool.description} +
+ )} + + {/* Parameters Table */} + {parameterRows.length > 0 && ( +
+ + Parameters + + + + )} + + {/* If tool was called, show the arguments used */} + {tool.called && tool.callData && ( +
+ + Called With + +
+
+              {JSON.stringify(tool.callData.arguments, null, 2)}
+            
+
+
+ )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx new file mode 100644 index 00000000000..2a2ceb644dc --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx @@ -0,0 +1,39 @@ +/** + * JSON view of tool definition + */ + +import { ParsedTool } from "./types"; + +interface JsonToolViewProps { + tool: ParsedTool; +} + +export function JsonToolView({ tool }: JsonToolViewProps) { + // Reconstruct the original tool definition + const toolJson = { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + }; + + return ( +
+      {JSON.stringify(toolJson, null, 2)}
+    
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolExpandedContent.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolExpandedContent.tsx new file mode 100644 index 00000000000..3c06dc7f08c --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolExpandedContent.tsx @@ -0,0 +1,52 @@ +/** + * Expanded content for a tool with view mode toggle + */ + +import { useState } from "react"; +import { Typography, Radio } from "antd"; +import { ParsedTool } from "./types"; +import { FormattedToolView } from "./FormattedToolView"; +import { JsonToolView } from "./JsonToolView"; + +const { Text } = Typography; + +type ViewMode = "formatted" | "json"; + +interface ToolExpandedContentProps { + tool: ParsedTool; +} + +export function ToolExpandedContent({ tool }: ToolExpandedContentProps) { + const [viewMode, setViewMode] = useState("formatted"); + + return ( +
+ {/* View Mode Toggle - Top Right */} +
+ + Description + + setViewMode(e.target.value)} + > + Formatted + JSON + +
+ + {viewMode === "formatted" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx new file mode 100644 index 00000000000..a5962a387af --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx @@ -0,0 +1,74 @@ +/** + * Individual tool item component with expandable details + */ + +import { useState } from "react"; +import { Typography, Tag } from "antd"; +import { ToolOutlined, RightOutlined, DownOutlined } from "@ant-design/icons"; +import { ParsedTool } from "./types"; +import { ToolExpandedContent } from "./ToolExpandedContent"; + +const { Text } = Typography; + +interface ToolItemProps { + tool: ParsedTool; +} + +export function ToolItem({ tool }: ToolItemProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ {/* Header Row - Always Visible */} +
setExpanded(!expanded)} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "12px 16px", + cursor: "pointer", + background: expanded ? "#fafafa" : "#fff", + transition: "background 0.2s", + }} + > +
+ + + {tool.index}. {tool.name} + +
+ +
+ + {tool.called ? "called" : "not called"} + + {expanded ? ( + + ) : ( + + )} +
+
+ + {/* Expanded Content */} + {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx new file mode 100644 index 00000000000..753a552b6db --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx @@ -0,0 +1,117 @@ +/** + * Core tests for Tools section + */ + +import { describe, it, expect } from "vitest"; +import { parseToolsFromLog } from "./utils"; +import { LogEntry } from "../columns"; + +describe("ToolsSection", () => { + it("should parse tools from request and match with response tool calls", () => { + const mockLog: LogEntry = { + request_id: "test-123", + api_key: "key", + team_id: "team", + model: "gpt-4", + model_id: "gpt-4", + call_type: "completion", + spend: 0.01, + total_tokens: 100, + prompt_tokens: 50, + completion_tokens: 50, + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + cache_hit: "none", + messages: JSON.stringify({ + model: "gpt-4", + messages: [{ role: "user", content: "What's the weather?" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + required: ["location"], + properties: { + location: { type: "string", description: "City name" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "search_web", + description: "Search the web", + parameters: { + type: "object", + required: ["query"], + properties: { + query: { type: "string", description: "Search query" }, + }, + }, + }, + }, + ], + }), + response: JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + id: "call_123", + type: "function", + function: { + name: "get_weather", + arguments: '{"location": "San Francisco"}', + }, + }, + ], + }, + }, + ], + }), + }; + + const tools = parseToolsFromLog(mockLog); + + expect(tools).toHaveLength(2); + expect(tools[0].name).toBe("get_weather"); + expect(tools[0].called).toBe(true); + expect(tools[0].callData?.arguments).toEqual({ location: "San Francisco" }); + expect(tools[1].name).toBe("search_web"); + expect(tools[1].called).toBe(false); + }); + + it("should return empty array when no tools in request", () => { + const mockLog: LogEntry = { + request_id: "test-456", + api_key: "key", + team_id: "team", + model: "gpt-4", + model_id: "gpt-4", + call_type: "completion", + spend: 0.01, + total_tokens: 100, + prompt_tokens: 50, + completion_tokens: 50, + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T00:00:01Z", + cache_hit: "none", + messages: JSON.stringify({ + model: "gpt-4", + messages: [{ role: "user", content: "Hello" }], + }), + response: JSON.stringify({ + choices: [{ message: { content: "Hi there!" } }], + }), + }; + + const tools = parseToolsFromLog(mockLog); + + expect(tools).toHaveLength(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx new file mode 100644 index 00000000000..7152db05599 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx @@ -0,0 +1,65 @@ +/** + * Tools section component that displays all available tools from the request + * and indicates which ones were actually called in the response + */ + +import { Collapse, Typography } from "antd"; +import { LogEntry } from "../columns"; +import { parseToolsFromLog } from "./utils"; +import { ToolItem } from "./ToolItem"; + +const { Text } = Typography; + +interface ToolsSectionProps { + log: LogEntry; +} + +export function ToolsSection({ log }: ToolsSectionProps) { + const tools = parseToolsFromLog(log); + + // Don't render if no tools + if (tools.length === 0) return null; + + // Calculate summary stats + const totalTools = tools.length; + const calledTools = tools.filter((t) => t.called).length; + + // Get preview of first 2 tool names + const toolNamePreview = tools + .slice(0, 2) + .map((t) => t.name) + .join(", "); + const hasMoreTools = tools.length > 2; + + return ( +
+ +

Tools

+ + {totalTools} provided, {calledTools} called + + + • {toolNamePreview} + {hasMoreTools && "..."} + +
+ ), + children: ( +
+ {tools.map((tool) => ( + + ))} +
+ ), + }, + ]} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/index.ts b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/index.ts new file mode 100644 index 00000000000..e3b8600b003 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/index.ts @@ -0,0 +1,7 @@ +/** + * Export main components and utilities for the Tools section + */ + +export { ToolsSection } from "./ToolsSection"; +export { parseToolsFromLog, hasTools } from "./utils"; +export type { ParsedTool, ToolDefinition, ToolCall } from "./types"; diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/types.ts b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/types.ts new file mode 100644 index 00000000000..92282fd1ca3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/types.ts @@ -0,0 +1,42 @@ +/** + * Type definitions for the Tools section + */ + +export interface ToolDefinition { + type: string; + function: { + name: string; + description?: string; + parameters?: Record; + }; +} + +export interface ToolCall { + id: string; + type: string; + function: { + name: string; + arguments: string; + }; +} + +export interface ParsedTool { + index: number; + name: string; + description: string; + parameters: Record; + called: boolean; + callData?: { + id: string; + name: string; + arguments: Record; + }; +} + +export interface ParameterRow { + key: string; + name: string; + type: string; + description: string; + required: boolean; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.test.ts b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.test.ts new file mode 100644 index 00000000000..75f975e9a13 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.test.ts @@ -0,0 +1,293 @@ +/** + * Tests for tool parsing utilities + */ + +import { describe, it, expect } from "vitest"; +import { parseToolsFromLog, hasTools } from "./utils"; +import { LogEntry } from "../columns"; + +describe("ToolsSection utils", () => { + describe("parseToolsFromLog", () => { + it("should return empty array when no tools in request", () => { + const log: Partial = { + request_id: "test-1", + messages: [], + response: {}, + }; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toEqual([]); + }); + + it("should parse tools from proxy_server_request", () => { + const log: Partial = { + request_id: "test-2", + proxy_server_request: { + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ], + }, + response: {}, + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + index: 1, + name: "get_weather", + description: "Get the current weather", + called: false, + }); + }); + + it("should parse tools from messages object format", () => { + const log: Partial = { + request_id: "test-3", + messages: { + tools: [ + { + type: "function", + function: { + name: "search_web", + description: "Search the web", + }, + }, + ], + }, + response: {}, + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("search_web"); + }); + + it("should mark tools as called when present in response", () => { + const log: Partial = { + request_id: "test-4", + proxy_server_request: { + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather", + }, + }, + { + type: "function", + function: { + name: "send_email", + description: "Send email", + }, + }, + ], + }, + response: { + choices: [ + { + message: { + tool_calls: [ + { + id: "call_123", + type: "function", + function: { + name: "get_weather", + arguments: '{"location": "San Francisco"}', + }, + }, + ], + }, + }, + ], + }, + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(2); + expect(result[0].called).toBe(true); + expect(result[0].callData).toBeDefined(); + expect(result[0].callData?.arguments).toEqual({ + location: "San Francisco", + }); + expect(result[1].called).toBe(false); + expect(result[1].callData).toBeUndefined(); + }); + + it("should handle string format request and response", () => { + const log: Partial = { + request_id: "test-5", + proxy_server_request: JSON.stringify({ + tools: [ + { + type: "function", + function: { + name: "calculate", + }, + }, + ], + }), + response: JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + id: "call_456", + type: "function", + function: { + name: "calculate", + arguments: '{"x": 5}', + }, + }, + ], + }, + }, + ], + }), + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(1); + expect(result[0].called).toBe(true); + }); + + it("should handle tools with no description or parameters", () => { + const log: Partial = { + request_id: "test-6", + proxy_server_request: { + tools: [ + { + type: "function", + function: { + name: "minimal_tool", + }, + }, + ], + }, + response: {}, + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + index: 1, + name: "minimal_tool", + description: "", + parameters: {}, + called: false, + }); + }); + + it("should handle invalid JSON in tool call arguments gracefully", () => { + const log: Partial = { + request_id: "test-7", + proxy_server_request: { + tools: [ + { + type: "function", + function: { + name: "test_tool", + }, + }, + ], + }, + response: { + choices: [ + { + message: { + tool_calls: [ + { + id: "call_789", + type: "function", + function: { + name: "test_tool", + arguments: "invalid json", + }, + }, + ], + }, + }, + ], + }, + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(1); + expect(result[0].called).toBe(true); + expect(result[0].callData?.arguments).toEqual({}); + }); + + it("should assign correct indices to multiple tools", () => { + const log: Partial = { + request_id: "test-8", + proxy_server_request: { + tools: [ + { type: "function", function: { name: "tool1" } }, + { type: "function", function: { name: "tool2" } }, + { type: "function", function: { name: "tool3" } }, + ], + }, + response: {}, + } as any; + + const result = parseToolsFromLog(log as LogEntry); + + expect(result).toHaveLength(3); + expect(result[0].index).toBe(1); + expect(result[1].index).toBe(2); + expect(result[2].index).toBe(3); + }); + }); + + describe("hasTools", () => { + it("should return false when no tools in request", () => { + const log: Partial = { + request_id: "test-9", + messages: [], + response: {}, + }; + + expect(hasTools(log as LogEntry)).toBe(false); + }); + + it("should return true when tools present in request", () => { + const log: Partial = { + request_id: "test-10", + proxy_server_request: { + tools: [ + { + type: "function", + function: { + name: "test_tool", + }, + }, + ], + }, + response: {}, + } as any; + + expect(hasTools(log as LogEntry)).toBe(true); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.ts b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.ts new file mode 100644 index 00000000000..33b21297c43 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.ts @@ -0,0 +1,130 @@ +/** + * Utility functions for parsing and processing tool data from log entries + */ + +import { LogEntry } from "../columns"; +import { ParsedTool, ToolDefinition, ToolCall } from "./types"; + +/** + * Parse raw data that might be a string or object + */ +function parseData(input: any): any { + if (typeof input === "string") { + try { + return JSON.parse(input); + } catch { + return input; + } + } + return input; +} + +/** + * Extract tools array from request data + */ +function extractToolsFromRequest(log: LogEntry): ToolDefinition[] { + // Check proxy_server_request first (most complete), then messages + const requestData = parseData(log.proxy_server_request || log.messages); + + if (!requestData) return []; + + // Handle array format (messages array) + if (Array.isArray(requestData)) { + // Tools are not typically in messages array, return empty + return []; + } + + // Handle object format (request body) + if (typeof requestData === "object" && requestData.tools) { + return Array.isArray(requestData.tools) ? requestData.tools : []; + } + + return []; +} + +/** + * Extract tool calls from response data + */ +function extractToolCallsFromResponse(log: LogEntry): ToolCall[] { + const responseData = parseData(log.response); + + if (!responseData || typeof responseData !== "object") return []; + + // OpenAI format: response.choices[0].message.tool_calls + const choices = responseData.choices; + if (Array.isArray(choices) && choices.length > 0) { + const firstChoice = choices[0]; + const message = firstChoice.message; + if (message && Array.isArray(message.tool_calls)) { + return message.tool_calls; + } + } + + return []; +} + +/** + * Parse safe JSON with fallback + */ +function parseSafeJson(jsonString: string): Record { + try { + return JSON.parse(jsonString); + } catch { + return {}; + } +} + +/** + * Main function to parse tools from a log entry + * Returns an array of tools with their definition and call status + */ +export function parseToolsFromLog(log: LogEntry): ParsedTool[] { + // Get tools from request + const requestTools = extractToolsFromRequest(log); + + if (requestTools.length === 0) { + return []; + } + + // Get tool calls from response + const toolCalls = extractToolCallsFromResponse(log); + const calledToolNames = new Set( + toolCalls.map((tc: ToolCall) => tc.function?.name).filter(Boolean) + ); + + // Map tool calls by name for quick lookup + const toolCallMap = new Map(); + toolCalls.forEach((tc: ToolCall) => { + const name = tc.function?.name; + if (name) { + toolCallMap.set(name, { + id: tc.id, + name: name, + arguments: parseSafeJson(tc.function?.arguments || "{}"), + }); + } + }); + + // Parse each tool definition + return requestTools.map((tool: ToolDefinition, index: number) => { + const func = tool.function || { name: `Tool ${index + 1}` }; + const name = func.name || `Tool ${index + 1}`; + + return { + index: index + 1, + name: name, + description: func.description || "", + parameters: func.parameters || {}, + called: calledToolNames.has(name), + callData: toolCallMap.get(name), + }; + }); +} + +/** + * Check if a log entry has any tools + */ +export function hasTools(log: LogEntry): boolean { + const requestTools = extractToolsFromRequest(log); + return requestTools.length > 0; +} diff --git a/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx index 008f0e388ca..807b2856590 100644 --- a/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/VectorStoreViewer.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { Collapse } from "antd"; import { getProviderLogoAndName } from "../provider_info_helpers"; interface VectorStoreContent { @@ -30,7 +31,6 @@ interface VectorStoreViewerProps { } export function VectorStoreViewer({ data }: VectorStoreViewerProps) { - const [sectionExpanded, setSectionExpanded] = useState(true); const [expandedResults, setExpandedResults] = useState>({}); if (!data || data.length === 0) { @@ -56,27 +56,16 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) { }; return ( -
-
setSectionExpanded(!sectionExpanded)} - > -
- - - -

Vector Store Requests

-
- {sectionExpanded ? "Click to collapse" : "Click to expand"} -
- - {sectionExpanded && ( -
+
+ Vector Store Requests, + children: ( +
{data.map((request, index) => (
@@ -168,7 +157,10 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
))}
- )} + ), + }, + ]} + />
); }