From 9e1c76e9d0c6fdc14788e6f4950a882dc7e105c2 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 30 Jan 2026 16:25:32 -0800 Subject: [PATCH] v1 - tool viewer in logs page --- .../LogDetailsDrawer/LogDetailsDrawer.tsx | 1 + .../ToolsSection/FormattedToolView.tsx | 124 ++++++++ .../view_logs/ToolsSection/JsonToolView.tsx | 39 +++ .../ToolsSection/ToolExpandedContent.tsx | 52 ++++ .../view_logs/ToolsSection/ToolItem.tsx | 74 +++++ .../ToolsSection/ToolsSection.test.tsx | 117 +++++++ .../view_logs/ToolsSection/ToolsSection.tsx | 42 +++ .../view_logs/ToolsSection/index.ts | 7 + .../view_logs/ToolsSection/types.ts | 42 +++ .../view_logs/ToolsSection/utils.test.ts | 293 ++++++++++++++++++ .../view_logs/ToolsSection/utils.ts | 130 ++++++++ 11 files changed, 921 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/FormattedToolView.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/JsonToolView.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolExpandedContent.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolItem.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/index.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/types.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.test.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/ToolsSection/utils.ts diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index 733cccdf1c9..b3e59cf507f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -27,6 +27,7 @@ import { SPACING_XLARGE, MESSAGE_REQUEST_ID_COPIED, } from "./constants"; +import { ToolsSection } from "../ToolsSection"; const { Text } = Typography; 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..fdeec2b249d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/ToolsSection/ToolsSection.tsx @@ -0,0 +1,42 @@ +/** + * Tools section component that displays all available tools from the request + * and indicates which ones were actually called in the response + */ + +import { 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; + + return ( +
+ + Tools + +
+ {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; +}