mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
v1 - tool viewer in logs page
This commit is contained in:
parent
437e9e23bd
commit
9e1c76e9d0
11 changed files with 921 additions and 0 deletions
|
|
@ -27,6 +27,7 @@ import {
|
|||
SPACING_XLARGE,
|
||||
MESSAGE_REQUEST_ID_COPIED,
|
||||
} from "./constants";
|
||||
import { ToolsSection } from "../ToolsSection";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden p-4 mb-6">
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 12,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Tools
|
||||
</Text>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{tools.map((tool) => (
|
||||
<ToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue