@@ -448,6 +459,7 @@ function RequestResponseSection({
),
children: (
+ {isRelayCapture &&
}
{viewMode === "pretty" ? (
+ {value || "No body captured"}
+
+ );
+}
+
+function RelayPayloadPreview({ request, response }: { request: any; response: any }) {
+ const requestBody = getPreviewText(request);
+ const responseBody = getPreviewText(response);
+ return (
+
+ );
+}
+
export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) {
const allPassed = guardrailEntries.every((e) => {
const status = e?.guardrail_status || e?.status;
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 cc087a611b0..ca75df52946 100644
--- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Drawer, Segmented } from "antd";
import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
-import { Bot, Sparkles, Wrench } from "lucide-react";
+import { Bot, Cable, Sparkles, Wrench } from "lucide-react";
import { LogEntry } from "../columns";
-import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants";
+import { AGENT_CALL_TYPES, MCP_CALL_TYPES, RELAY_CALL_TYPES } from "../constants";
import { getEventDisplayName } from "../utils";
import { DrawerHeader } from "./DrawerHeader";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
@@ -49,6 +49,7 @@ interface TraceEventRowProps {
function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const isAgent = AGENT_CALL_TYPES.includes(row.call_type);
+ const isRelay = RELAY_CALL_TYPES.includes(row.call_type);
const durationValue =
row.request_duration_ms != null
? (row.request_duration_ms / 1000).toFixed(3)
@@ -69,6 +70,8 @@ function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) {
) : isAgent ? (
+ ) : isRelay ? (
+
) : (
)}
diff --git a/ui/litellm-dashboard/src/components/view_logs/TypeBadges.test.tsx b/ui/litellm-dashboard/src/components/view_logs/TypeBadges.test.tsx
index e3467310265..d63df05cb3e 100644
--- a/ui/litellm-dashboard/src/components/view_logs/TypeBadges.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/TypeBadges.test.tsx
@@ -1,6 +1,14 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { LlmBadge, McpBadge, AgentBadge } from "./TypeBadges";
+import {
+ LlmBadge,
+ McpBadge,
+ AgentBadge,
+ RelayBadge,
+ RelaySourceBadge,
+ RelayTypeBadge,
+ getRelaySource,
+} from "./TypeBadges";
describe("TypeBadges", () => {
describe("LlmBadge", () => {
@@ -43,4 +51,43 @@ describe("TypeBadges", () => {
expect(screen.getByText("12")).toBeInTheDocument();
});
});
+
+ describe("RelayBadge", () => {
+ it("should render with default 'litellm-relay' text when no count is provided", () => {
+ render();
+ expect(screen.getByText("litellm-relay")).toBeInTheDocument();
+ });
+ });
+
+ describe("RelaySourceBadge", () => {
+ it("should render Notion source with logo", () => {
+ render();
+ expect(screen.getByRole("img", { name: "Notion logo" })).toBeInTheDocument();
+ expect(screen.getByText("Notion")).toBeInTheDocument();
+ });
+
+ it("should render Codex source with logo", () => {
+ render();
+ expect(screen.getByRole("img", { name: "Codex logo" })).toBeInTheDocument();
+ expect(screen.getByText("Codex")).toBeInTheDocument();
+ });
+
+ it("should derive source from relay metadata app", () => {
+ expect(getRelaySource({ metadata: { app: "notion" }, model: "local-ai" })).toBe("notion");
+ });
+
+ it("should derive source from relay model when metadata is missing", () => {
+ expect(getRelaySource({ model: "codex-ai" })).toBe("codex");
+ });
+ });
+
+ describe("RelayTypeBadge", () => {
+ it("should render relay type next to the captured app", () => {
+ render();
+
+ expect(screen.getByText("litellm-relay")).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: "Notion logo" })).toBeInTheDocument();
+ expect(screen.getByText("Notion")).toBeInTheDocument();
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx b/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx
index 77ff4cfef62..a8c3ef751aa 100644
--- a/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/TypeBadges.tsx
@@ -1,7 +1,9 @@
/**
- * Compact type-indicator badges for LLM, Agent, and MCP log entries.
+ * Compact type-indicator badges for LLM, Agent, MCP, and Relay log entries.
* Used in the request logs table and session type column.
*/
+import { Cable, Monitor } from "lucide-react";
+import { resolveLogoSrc } from "@/lib/assetPaths";
export const SparkleIcon = ({ size = 12 }: { size?: number }) => (
);
+export const RelayIcon = ({ size = 12 }: { size?: number }) => ;
+
export const LlmBadge = ({ count }: { count?: number }) => (
@@ -77,3 +81,97 @@ export const AgentBadge = ({ count }: { count?: number }) => (
{count != null ? count : "Agent"}
);
+
+export const RelayBadge = ({ count }: { count?: number }) => (
+
+
+ {count != null ? count : "litellm-relay"}
+
+);
+
+type RelaySourceLike = {
+ metadata?: Record;
+ model?: string;
+ request_tags?: Record | string[] | string;
+};
+
+const RELAY_SOURCE_LABELS: Record = {
+ notion: "Notion",
+ codex: "Codex",
+};
+
+const RELAY_SOURCE_LOGOS: Record = {
+ notion: "/ui/assets/logos/notion.svg",
+ codex: "/ui/assets/logos/openai_small.svg",
+};
+
+const normalizeRelaySource = (value: unknown): string | undefined => {
+ if (typeof value !== "string") return undefined;
+ const normalized = value.trim().toLowerCase();
+ if (!normalized) return undefined;
+ if (normalized === "litellm-relay") return undefined;
+ if (normalized.includes("notion")) return "notion";
+ if (normalized.includes("codex")) return "codex";
+ return normalized.replace(/-ai$/, "");
+};
+
+const getTagSource = (requestTags: RelaySourceLike["request_tags"]): string | undefined => {
+ if (Array.isArray(requestTags)) {
+ return requestTags.map(normalizeRelaySource).find(Boolean);
+ }
+ if (typeof requestTags === "string") {
+ try {
+ const parsed = JSON.parse(requestTags);
+ return getTagSource(parsed);
+ } catch {
+ return normalizeRelaySource(requestTags);
+ }
+ }
+ return undefined;
+};
+
+export const getRelaySource = (entry: RelaySourceLike): string => {
+ return (
+ normalizeRelaySource(entry.metadata?.app) ||
+ normalizeRelaySource(entry.metadata?.relay_app) ||
+ normalizeRelaySource(entry.metadata?.shadow_source) ||
+ normalizeRelaySource(entry.metadata?.host) ||
+ getTagSource(entry.request_tags) ||
+ normalizeRelaySource(entry.model) ||
+ "unknown"
+ );
+};
+
+export const getRelaySourceLabel = (source: string) => {
+ return RELAY_SOURCE_LABELS[source] || source.charAt(0).toUpperCase() + source.slice(1);
+};
+
+export const RelaySourceLogo = ({ source, size = 16 }: { source: string; size?: number }) => {
+ const logo = RELAY_SOURCE_LOGOS[source];
+ if (logo) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+};
+
+export const RelaySourceBadge = ({ source }: { source: string }) => (
+
+
+ {getRelaySourceLabel(source)}
+
+);
+
+export const RelayTypeBadge = ({ source }: { source: string }) => (
+
+
+
+
+);
diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx
index afdd17813f3..2743eae8309 100644
--- a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx
@@ -54,3 +54,66 @@ describe("Cost column", () => {
expect(await screen.findByText("$0.00012345678")).toBeInTheDocument();
});
});
+
+describe("view logs columns", () => {
+ const relayLog = logEntry({
+ request_id: "req-relay",
+ api_key: "hashed-relay-key",
+ team_id: "",
+ model: "notion-ai",
+ api_base: "https://www.notion.so",
+ call_type: "litellm-relay",
+ total_tokens: 0,
+ prompt_tokens: 0,
+ completion_tokens: 0,
+ metadata: {
+ app: "notion",
+ status: "success",
+ status_code: 200,
+ user_api_key: null,
+ user_api_key_alias: "relay-key",
+ user_api_key_team_alias: null,
+ },
+ request_tags: { source: "notion" },
+ proxy_server_request: {},
+ status: "success",
+ });
+
+ it("should render relay type with captured app logo and name", () => {
+ render( row.request_id} />);
+
+ expect(screen.getByText("litellm-relay")).toBeInTheDocument();
+ expect(screen.getAllByText("Notion").length).toBeGreaterThan(0);
+ expect(screen.getAllByRole("img", { name: "Notion logo" }).length).toBeGreaterThan(0);
+ expect(screen.queryByRole("columnheader", { name: "Source" })).not.toBeInTheDocument();
+ expect(screen.queryByText("LLM")).not.toBeInTheDocument();
+ });
+
+ it("should render collector rows with relay metadata as relay logs", () => {
+ const legacyCollectorLog = {
+ ...relayLog,
+ request_id: "collector-01f159da",
+ call_type: "completion",
+ metadata: {
+ ...relayLog.metadata,
+ app: "codex",
+ source: "litellm-relay",
+ },
+ model: "codex-ai",
+ request_tags: ["litellm-relay", "codex"],
+ };
+
+ render( row.request_id} />);
+
+ expect(screen.getByText("litellm-relay")).toBeInTheDocument();
+ expect(screen.getAllByText("Codex").length).toBeGreaterThan(0);
+ expect(screen.getAllByRole("img", { name: "Codex logo" }).length).toBeGreaterThan(0);
+ expect(screen.queryByText("LLM")).not.toBeInTheDocument();
+ });
+
+ it("should fall back to row api_key when relay metadata does not include a key hash", () => {
+ render( row.request_id} />);
+
+ expect(screen.getByText("hashed-relay-key")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx
index 1d0f3f33d08..65205ce09ca 100644
--- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx
@@ -5,8 +5,18 @@ import { Tooltip } from "antd";
import React from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
-import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
-import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
+import { AGENT_CALL_TYPES, MCP_CALL_TYPES, RELAY_CALL_TYPES } from "./constants";
+import {
+ AgentBadge,
+ AgentIcon,
+ LlmBadge,
+ McpBadge,
+ RelayIcon,
+ RelayTypeBadge,
+ SparkleIcon,
+ WrenchIcon,
+ getRelaySource,
+} from "./TypeBadges";
/** API sort field mapping for /spend/logs/ui endpoint */
export const LOGS_SORT_FIELD_MAP = {
@@ -56,7 +66,7 @@ export type LogEntry = {
metadata?: Record;
cache_hit: string;
cache_key?: string;
- request_tags?: Record;
+ request_tags?: Record | string[] | string;
requester_ip_address?: string;
messages: string | any[] | Record;
response: string | any[] | Record;
@@ -104,6 +114,32 @@ const SortableHeader = ({
);
+const requestTagsIncludeRelay = (requestTags: LogEntry["request_tags"]): boolean => {
+ if (!requestTags) return false;
+ if (Array.isArray(requestTags)) {
+ return requestTags.some((tag) => String(tag).toLowerCase() === "litellm-relay");
+ }
+ if (typeof requestTags === "string") {
+ try {
+ return requestTagsIncludeRelay(JSON.parse(requestTags));
+ } catch {
+ return requestTags.toLowerCase().includes("litellm-relay");
+ }
+ }
+ return Object.entries(requestTags).some(
+ ([key, value]) => key.toLowerCase().includes("litellm-relay") || String(value).toLowerCase().includes("litellm-relay"),
+ );
+};
+
+const isRelayLog = (row: LogEntry): boolean => {
+ return (
+ RELAY_CALL_TYPES.includes(row.call_type) ||
+ row.metadata?.source === "litellm-relay" ||
+ requestTagsIncludeRelay(row.request_tags) ||
+ (row.request_id?.startsWith("collector-") && getRelaySource(row) !== "unknown")
+ );
+};
+
export const createColumns = (sortProps?: LogsSortProps): ColumnDef
[] => [
{
header: sortProps
@@ -124,17 +160,19 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[]
{
header: "Type",
id: "type",
- size: 90,
+ size: 240,
cell: (info: any) => {
const row = info.row.original;
const sessionCount = row.session_total_count || 1;
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const isAgent = AGENT_CALL_TYPES.includes(row.call_type);
- const sessionLlmCount = row.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount);
+ const isRelay = isRelayLog(row);
+ const sessionLlmCount = row.session_llm_count ?? (isMcp || isAgent || isRelay ? 0 : sessionCount);
const sessionAgentCount = row.session_agent_count ?? (isAgent ? sessionCount : 0);
const sessionMcpCount = row.session_mcp_count ?? (isMcp ? sessionCount : 0);
if (isMcp) return ;
+ if (isRelay) return ;
if (isAgent && sessionCount <= 1) return ;
if (sessionCount <= 1) return ;
@@ -155,6 +193,12 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[]
>
)}
+ {isRelay && (
+ <>
+ ·
+
+ >
+ )}
);
@@ -162,6 +206,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[]
sessionLlmCount > 0 && `${sessionLlmCount} LLM`,
sessionAgentCount > 0 && `${sessionAgentCount} Agent`,
sessionMcpCount > 0 && `${sessionMcpCount} MCP`,
+ isRelay && "litellm-relay",
].filter(Boolean);
return {sessionTypeBadge};
},
@@ -284,17 +329,25 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[]
header: "Team Name",
accessorKey: "metadata.user_api_key_team_alias",
size: 150,
- cell: (info: any) => (
-
- {String(info.getValue() || "-")}
-
- ),
+ cell: (info: any) => {
+ const row = info.row.original;
+ const value =
+ info.getValue() || row.metadata?.user_api_key_team_id || row.team_id || (row.api_key ? "No team" : "-");
+ return (
+
+ {String(value)}
+
+ );
+ },
},
{
header: "Key Hash",
accessorKey: "metadata.user_api_key",
size: 110,
- cell: (info: any) => ,
+ cell: (info: any) => {
+ const row = info.row.original;
+ return ;
+ },
},
{
header: "Key Alias",
diff --git a/ui/litellm-dashboard/src/components/view_logs/constants.ts b/ui/litellm-dashboard/src/components/view_logs/constants.ts
index 5b0b1d0fee3..bb1fdb6a540 100644
--- a/ui/litellm-dashboard/src/components/view_logs/constants.ts
+++ b/ui/litellm-dashboard/src/components/view_logs/constants.ts
@@ -18,6 +18,9 @@ export const MCP_CALL_TYPES = ["call_mcp_tool", "list_mcp_tools"];
/** Call types that represent agent/A2A requests (e.g. asend_message). */
export const AGENT_CALL_TYPES = ["asend_message"];
+/** Call types that represent local collector captures from LiteLLM Relay. */
+export const RELAY_CALL_TYPES = ["litellm-relay"];
+
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
{ label: "Last Minute", value: 1, unit: "minutes" },
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx
index ee08712e56b..51fa9c1dfac 100644
--- a/ui/litellm-dashboard/src/components/view_logs/index.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx
@@ -10,7 +10,7 @@ import { keyInfoV1Call } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogs from "./audit_logs";
import { createColumns, LogEntry, type LogsSortField } from "./columns";
-import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
+import { AGENT_CALL_TYPES, MCP_CALL_TYPES, RELAY_CALL_TYPES } from "./constants";
import { getLogFilterOptions } from "./filter_options";
import { useLogFilterLogic, defaultFilters, type LogFilterState } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
@@ -154,6 +154,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
acc[log.session_id].mcp += 1;
} else if (AGENT_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].agent += 1;
+ } else if (RELAY_CALL_TYPES.includes(log.call_type)) {
+ // Relay captures are not LLM calls; they get their own Type badge.
} else {
acc[log.session_id].llm += 1;
}
@@ -163,14 +165,22 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
);
// Build a single-pass map of session_id → representative request_id.
- // Prefers an LLM row over an MCP row as the representative.
- const sessionRepresentativeMap = new Map();
+ // Prefer higher-signal execution rows over collector/tool rows.
+ const sessionRepresentativeMap = new Map();
for (const log of searchedLogs) {
if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
+ const isRelay = RELAY_CALL_TYPES.includes(log.call_type);
+ const isAgent = AGENT_CALL_TYPES.includes(log.call_type);
+ let priority = 2;
+ if (isRelay || isMcp) {
+ priority = 0;
+ } else if (isAgent) {
+ priority = 1;
+ }
const existing = sessionRepresentativeMap.get(log.session_id);
- if (!existing || (existing.isMcp && !isMcp)) {
- sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
+ if (!existing || priority > existing.priority) {
+ sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, priority });
}
}
diff --git a/ui/litellm-dashboard/src/components/view_logs/utils.ts b/ui/litellm-dashboard/src/components/view_logs/utils.ts
index 37a82a7523e..ac64da417b9 100644
--- a/ui/litellm-dashboard/src/components/view_logs/utils.ts
+++ b/ui/litellm-dashboard/src/components/view_logs/utils.ts
@@ -1,4 +1,4 @@
-import { MCP_CALL_TYPES } from "./constants";
+import { MCP_CALL_TYPES, RELAY_CALL_TYPES } from "./constants";
/**
* Derive a short, human-readable display name for a log entry.
@@ -7,6 +7,7 @@ import { MCP_CALL_TYPES } from "./constants";
export function getEventDisplayName(callType: string, model: string): string {
const raw = (model || "").trim();
const isMcp = MCP_CALL_TYPES.includes(callType);
+ const isRelay = RELAY_CALL_TYPES.includes(callType);
if (isMcp) {
return (
@@ -19,6 +20,10 @@ export function getEventDisplayName(callType: string, model: string): string {
);
}
+ if (isRelay) {
+ return raw || "litellm-relay";
+ }
+
const lastSegment = raw.split("/").pop() || raw;
const noSuffix = lastSegment.replace(/-20\d{6}.*$/i, "").replace(/:.*$/, "");
const claudeMatch = noSuffix.match(/claude-[a-z0-9-]+/i);