Merge litellm_add_pretty_view_logs into litellm_v2_logs_view_original

Resolved conflicts by favoring refactor changes:
- Kept refactored DrawerHeader.tsx with Antd Space and copyable prop
- Kept refactored LogDetailsDrawer.tsx with utils imports
- Kept refactored constants.ts without message constants
- Removed clipboardUtils.ts (replaced with Antd's built-in copy)
- Used incoming CostBreakdownViewer.tsx with Antd Collapse component

Added from litellm_add_pretty_view_logs:
- New ToolsSection components for displaying tools in logs
- Updated GuardrailViewer with tests
- Updated VectorStoreViewer
- Prometheus integration updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ishaan Jaffer 2026-01-30 18:40:03 -08:00
commit 2570f3c0fe
16 changed files with 1100 additions and 108 deletions

View file

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

View file

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

View file

@ -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<string, number>;
original_cost?: number;
discount_percent?: number;
discount_amount?: number;
@ -61,18 +60,22 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Accordion>
<AccordionHeader className="p-4 border-b hover:bg-gray-50 transition-colors text-left">
<div className="flex items-center justify-between w-full">
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
<div className="flex items-center space-x-2 mr-4">
<span className="text-sm text-gray-500">Total:</span>
<span className="text-sm font-semibold text-gray-900">{formatCost(totalSpend)}</span>
</div>
</div>
</AccordionHeader>
<AccordionBody className="px-0">
<div className="p-6 space-y-4">
<Collapse
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div className="flex items-center justify-between w-full">
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
<div className="flex items-center space-x-2 mr-4">
<span className="text-sm text-gray-500">Total:</span>
<span className="text-sm font-semibold text-gray-900">{formatCost(totalSpend)}</span>
</div>
</div>
),
children: (
<div className="p-6 space-y-4">
{/* Step 1: Base Token Costs */}
<div className="space-y-2 max-w-2xl">
<div className="flex text-sm">
@ -89,17 +92,6 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
</div>
)}
{/* Additional Costs (free-form) */}
{costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && (
<>
{Object.entries(costBreakdown.additional_costs).map(([key, value]) => (
<div key={key} className="flex text-sm">
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
<span className="text-gray-900">{formatCost(value)}</span>
</div>
))}
</>
)}
</div>
{/* Subtotal / Original Cost */}
@ -161,8 +153,10 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
</div>
</div>
</div>
</AccordionBody>
</Accordion>
),
},
]}
/>
</div>
);
};

View file

@ -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(<GuardrailViewer data={data} />);
const { container } = renderWithProviders(<GuardrailViewer data={data} />);
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 () => {

View file

@ -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 (
<div className="bg-white rounded-lg shadow mb-6">
<div
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<div className="flex items-center gap-2">
<svg
className={`w-5 h-5 text-gray-600 transition-transform ${sectionExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-lg font-medium">Guardrail Information</h3>
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-gray-900">Guardrail Information</h3>
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
<span
className={`ml-2 px-2 py-1 rounded-md text-xs font-medium inline-block ${
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
}`}
>
{aggregatedStatus}
</span>
</Tooltip>
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
<span
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
}`}
>
{aggregatedStatus}
</span>
</Tooltip>
<span className="ml-2 font-mono text-sm text-gray-600">{primaryName}</span>
<span className="font-mono text-sm text-gray-600">{primaryName}</span>
{totalMaskedEntities > 0 && (
<span className="ml-2 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
</span>
)}
</div>
<span className="text-sm text-gray-500">{sectionExpanded ? "Click to collapse" : "Click to expand"}</span>
</div>
{sectionExpanded && (
<div className="p-4 space-y-6">
{guardrailEntries.map((entry, index) => (
<GuardrailDetails
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
entry={entry}
index={index}
total={guardrailEntries.length}
/>
))}
</div>
)}
{totalMaskedEntities > 0 && (
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
</span>
)}
</div>
),
children: (
<div className="p-4 space-y-6">
{guardrailEntries.map((entry, index) => (
<GuardrailDetails
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
entry={entry}
index={index}
total={guardrailEntries.length}
/>
))}
</div>
),
},
]}
/>
</div>
);
};

View file

@ -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) => (
<Text code>
{name}
{record.required && <Text type="danger">*</Text>}
</Text>
),
},
{
title: "Type",
dataIndex: "type",
key: "type",
render: (type: string) => (
<Text code style={{ color: "#1890ff" }}>
{type}
</Text>
),
},
{
title: "Description",
dataIndex: "description",
key: "description",
render: (desc: string) => <Text type="secondary">{desc}</Text>,
},
];
return (
<div>
{/* Description */}
{tool.description && (
<div style={{ marginBottom: 16 }}>
<Text style={{ lineHeight: 1.6 }}>{tool.description}</Text>
</div>
)}
{/* Parameters Table */}
{parameterRows.length > 0 && (
<div>
<Text
type="secondary"
style={{
fontSize: 12,
display: "block",
marginBottom: 8,
}}
>
Parameters
</Text>
<Table
dataSource={parameterRows}
columns={columns}
pagination={false}
size="small"
bordered
/>
</div>
)}
{/* If tool was called, show the arguments used */}
{tool.called && tool.callData && (
<div style={{ marginTop: 16 }}>
<Text
type="secondary"
style={{
fontSize: 12,
display: "block",
marginBottom: 8,
}}
>
Called With
</Text>
<div
style={{
background: "#f6ffed",
border: "1px solid #b7eb8f",
borderRadius: 4,
padding: 12,
}}
>
<pre
style={{
margin: 0,
fontSize: 12,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{JSON.stringify(tool.callData.arguments, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
}

View file

@ -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 (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontSize: 12,
background: "#fafafa",
padding: 12,
borderRadius: 4,
maxHeight: 300,
overflow: "auto",
}}
>
{JSON.stringify(toolJson, null, 2)}
</pre>
);
}

View file

@ -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<ViewMode>("formatted");
return (
<div>
{/* View Mode Toggle - Top Right */}
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: 12,
}}
>
<Text type="secondary" style={{ fontSize: 12 }}>
Description
</Text>
<Radio.Group
size="small"
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
>
<Radio.Button value="formatted">Formatted</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
</div>
{viewMode === "formatted" ? (
<FormattedToolView tool={tool} />
) : (
<JsonToolView tool={tool} />
)}
</div>
);
}

View file

@ -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 (
<div
style={{
border: "1px solid #f0f0f0",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* Header Row - Always Visible */}
<div
onClick={() => setExpanded(!expanded)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
cursor: "pointer",
background: expanded ? "#fafafa" : "#fff",
transition: "background 0.2s",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ToolOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
<Text style={{ fontSize: 14 }}>
{tool.index}. {tool.name}
</Text>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Tag color={tool.called ? "blue" : "default"}>
{tool.called ? "called" : "not called"}
</Tag>
{expanded ? (
<DownOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
) : (
<RightOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
)}
</div>
</div>
{/* Expanded Content */}
{expanded && (
<div
style={{
padding: "16px",
borderTop: "1px solid #f0f0f0",
background: "#fff",
}}
>
<ToolExpandedContent tool={tool} />
</div>
)}
</div>
);
}

View file

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

View file

@ -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 (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
expandIconPosition="start"
items={[
{
key: "1",
label: (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<h3 className="text-lg font-medium text-gray-900">Tools</h3>
<Text type="secondary" style={{ fontSize: 14 }}>
{totalTools} provided, {calledTools} called
</Text>
<Text type="secondary" style={{ fontSize: 14 }}>
{toolNamePreview}
{hasMoreTools && "..."}
</Text>
</div>
),
children: (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{tools.map((tool) => (
<ToolItem key={tool.name} tool={tool} />
))}
</div>
),
},
]}
/>
</div>
);
}

View file

@ -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";

View file

@ -0,0 +1,42 @@
/**
* Type definitions for the Tools section
*/
export interface ToolDefinition {
type: string;
function: {
name: string;
description?: string;
parameters?: Record<string, any>;
};
}
export interface ToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}
export interface ParsedTool {
index: number;
name: string;
description: string;
parameters: Record<string, any>;
called: boolean;
callData?: {
id: string;
name: string;
arguments: Record<string, any>;
};
}
export interface ParameterRow {
key: string;
name: string;
type: string;
description: string;
required: boolean;
}

View file

@ -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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
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<LogEntry> = {
request_id: "test-10",
proxy_server_request: {
tools: [
{
type: "function",
function: {
name: "test_tool",
},
},
],
},
response: {},
} as any;
expect(hasTools(log as LogEntry)).toBe(true);
});
});
});

View file

@ -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<string, any> {
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<string, any>();
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;
}

View file

@ -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<Record<string, boolean>>({});
if (!data || data.length === 0) {
@ -56,27 +56,16 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
};
return (
<div className="bg-white rounded-lg shadow mb-6">
<div
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
onClick={() => setSectionExpanded(!sectionExpanded)}
>
<div className="flex items-center">
<svg
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? "transform rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<h3 className="text-lg font-medium">Vector Store Requests</h3>
</div>
<span className="text-sm text-gray-500">{sectionExpanded ? "Click to collapse" : "Click to expand"}</span>
</div>
{sectionExpanded && (
<div className="p-4">
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Collapse
defaultActiveKey={["1"]}
expandIconPosition="start"
items={[
{
key: "1",
label: <h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>,
children: (
<div className="p-4">
{data.map((request, index) => (
<div key={index} className="mb-6 last:mb-0">
<div className="bg-white rounded-lg border p-4 mb-4">
@ -168,7 +157,10 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
</div>
))}
</div>
)}
),
},
]}
/>
</div>
);
}