,
@@ -295,6 +296,46 @@ describe("LogDetailContent", () => {
expect(screen.queryByRole("tab", { name: "Request" })).not.toBeInTheDocument();
});
+ it.each([
+ ["Request", true],
+ ["Response", true],
+ ["Metadata", true],
+ ["Request", false],
+ ] as const)("copies %s through the fallback and reports its outcome (%s)", async (section, success) => {
+ const user = userEvent.setup();
+ const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
+ const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, "execCommand");
+ const writeText = vi.fn<(value: string) => boolean>().mockReturnValue(success);
+ Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: (command: string) =>
+ command === "copy" && writeText((document.activeElement as HTMLTextAreaElement).value),
+ });
+ vi.mocked(toast.success).mockClear();
+ const log = createLogEntry();
+ const expected = { Request: log.messages, Response: log.response, Metadata: log.metadata }[section];
+
+ try {
+ render(
);
+ if (section !== "Metadata") {
+ await user.click(screen.getByRole("tab", { name: "JSON", exact: true }));
+ await user.click(screen.getByRole("tab", { name: section, exact: true }));
+ }
+ const label = section === "Metadata" ? "Copy Metadata" : "Copy JSON";
+ const button = screen.getByRole("button", { name: label, exact: true });
+ await user.click(button);
+
+ expect(writeText).toHaveBeenCalledWith(JSON.stringify(expected, null, 2));
+ expect(button).toHaveAccessibleName(success ? "Copied!" : label);
+ expect(toast.success).toHaveBeenCalledTimes(success ? 1 : 0);
+ } finally {
+ if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
+ if (execCommandDescriptor) Object.defineProperty(document, "execCommand", execCommandDescriptor);
+ else Reflect.deleteProperty(document, "execCommand");
+ }
+ });
+
it.each(["object", "serialized", "messages only", "null captures"])(
"preserves request inspection and copying for classifier logs with %s data",
async (shape) => {
diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx
index 97393e24fdd..f740b6c586a 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx
@@ -1,3 +1,4 @@
+import { copyToClipboard } from "@/utils/dataUtils";
import { useState } from "react";
import { Check, ChevronDown, ChevronRight, CircleAlert, Copy, Info } from "lucide-react";
import moment from "moment";
@@ -9,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { LogEntry } from "../types";
-import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
+import { formatNumberWithCommas } from "@/utils/dataUtils";
import { PROMPT_CACHE_CREATION_TOOLTIP, PROMPT_CACHE_READ_TOOLTIP } from "@/utils/promptCacheUsage";
import GuardrailViewer from "./sections/GuardrailViewer/GuardrailViewer";
import EvalViewer from "./sections/EvalViewer/EvalViewer";
@@ -304,9 +305,10 @@ function CopyButton({
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
- if (!(await copyToClipboard(getText()))) return;
- setCopied(true);
- setTimeout(() => setCopied(false), 1200);
+ if (await copyToClipboard(getText())) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1200);
+ }
};
return (
diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx
index 1321a4a152a..36ab70ecf2a 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.test.tsx
@@ -1,3 +1,5 @@
+import userEvent from "@testing-library/user-event";
+import { copyToClipboard } from "@/utils/dataUtils";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
@@ -6,6 +8,11 @@ import { sessionSpendLogsCall } from "../../networking";
import { LogEntry } from "../types";
import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells";
+vi.mock("@/utils/dataUtils", async (importOriginal) => ({
+ ...(await importOriginal
()),
+ copyToClipboard: vi.fn(),
+}));
+
vi.mock("../../networking", () => ({
sessionSpendLogsCall: vi.fn(),
}));
@@ -201,3 +208,18 @@ describe("LogDetailsDrawer session sidebar auto-router icon", () => {
expect(directRow.querySelector(".lucide-waypoints")).toBeNull();
});
});
+
+describe("LogDetailsDrawer copying", () => {
+ it.each([true, false])("confirms a session id only when copying succeeds (%s)", async (success) => {
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
+ vi.mocked(copyToClipboard).mockReset().mockResolvedValue(success);
+ renderSessionDrawer();
+
+ const button = await screen.findByRole("button", { name: "Copy trace id" });
+ await user.click(button);
+
+ expect(copyToClipboard).toHaveBeenCalledWith("session-1");
+ expect(button.querySelector(".lucide-check") !== null).toBe(success);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx
index dafb1748dc3..23ad629d572 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailsDrawer.tsx
@@ -1,3 +1,4 @@
+import { copyToClipboard } from "@/utils/dataUtils";
import { useEffect, useMemo, useState } from "react";
import { Bot, Check, Copy, Sparkles, Wrench } from "lucide-react";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -13,7 +14,7 @@ import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent";
import { sessionSpendLogsCall } from "../../networking";
import { useQuery } from "@tanstack/react-query";
-import { copyToClipboard, getSpendString } from "@/utils/dataUtils";
+import { getSpendString } from "@/utils/dataUtils";
import { normalizeGuardrailEntries, sortSessionLogs, SessionLogSortMode } from "./utils";
import { DRAWER_WIDTH } from "./constants";
import { useLogDetails } from "@/app/(dashboard)/hooks/logDetails/useLogDetails";
@@ -288,9 +289,11 @@ export function LogDetailsDrawer({
const leftPanelDisplayId = leftPanelId.length > 14 ? `${leftPanelId.slice(0, 11)}...` : leftPanelId;
const handleCopyLeftPanelId = async () => {
- if (!(await copyToClipboard(leftPanelId))) return;
- setCopiedLeftPanelId(true);
- setTimeout(() => setCopiedLeftPanelId(false), 1200);
+ if (!leftPanelId) return;
+ if (await copyToClipboard(leftPanelId)) {
+ setCopiedLeftPanelId(true);
+ setTimeout(() => setCopiedLeftPanelId(false), 1200);
+ }
};
if (!currentLog || !enrichedLog) return null;
diff --git a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx
index 3dda7c5fa65..7b4c1a987a1 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.test.tsx
@@ -1,12 +1,15 @@
import React from "react";
-import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { OutputCard } from "./OutputCard";
import { ParsedMessage } from "./prettyMessagesTypes";
+import { copyToClipboard } from "@/utils/dataUtils";
+import { toast } from "@/lib/toast";
+
+vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn() }));
describe("OutputCard", () => {
- const mockWriteText = vi.fn().mockResolvedValue(undefined);
const mockMessage: ParsedMessage = {
role: "assistant",
content: "This is a test response",
@@ -14,13 +17,7 @@ describe("OutputCard", () => {
beforeEach(() => {
vi.clearAllMocks();
- Object.defineProperty(navigator, "clipboard", {
- value: {
- writeText: mockWriteText,
- },
- writable: true,
- configurable: true,
- });
+ vi.mocked(copyToClipboard).mockResolvedValue(true);
});
it("should render the OutputCard component", () => {
@@ -53,20 +50,16 @@ describe("OutputCard", () => {
expect(screen.getByText(/Cost: \$0\.002500/)).toBeInTheDocument();
});
- it("should copy message content when copy button is clicked", async () => {
+ it.each([true, false])("delegates copying and feedback to the helper (%s)", async (success) => {
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
+ vi.mocked(copyToClipboard).mockResolvedValue(success);
render();
- const copyButton = screen.getByRole("button", { name: /copy/i });
+ await user.click(screen.getByRole("button", { name: /copy/i }));
- expect(copyButton).toBeInTheDocument();
-
- await act(async () => {
- fireEvent.click(copyButton);
- });
-
- await waitFor(() => {
- expect(mockWriteText).toHaveBeenCalledWith("This is a test response");
- });
+ expect(copyToClipboard).toHaveBeenCalledWith("This is a test response", "Output copied");
+ expect(toast.success).not.toHaveBeenCalled();
});
it("should not copy when message is null and copy button is clicked", async () => {
@@ -80,7 +73,7 @@ describe("OutputCard", () => {
await user.click(copyButton);
await waitFor(() => {
- expect(mockWriteText).not.toHaveBeenCalled();
+ expect(copyToClipboard).not.toHaveBeenCalled();
});
});
diff --git a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx
index 1ede243d183..7daa8b02546 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/OutputCard.tsx
@@ -19,7 +19,11 @@ interface OutputCardProps {
export function OutputCard({ message, completionTokens, outputCost }: OutputCardProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
- const handleCopy = () => copyToClipboard(message?.content, "Output copied");
+ const handleCopy = () => {
+ if (!message) return;
+
+ void copyToClipboard(message.content || "", "Output copied");
+ };
return (
diff --git a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx
index 201c9e74ce8..d5280c35983 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.test.tsx
@@ -1,9 +1,12 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { copyToClipboard } from "@/utils/dataUtils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView";
+vi.mock("@/utils/dataUtils", () => ({ copyToClipboard: vi.fn().mockResolvedValue(true) }));
+
const sampleRealtimeResponse = {
usage: {
total_tokens: 587,
@@ -142,15 +145,20 @@ describe("isRealtimeResponse", () => {
});
describe("RealtimePrettyView", () => {
- const mockWriteText = vi.fn().mockResolvedValue(undefined);
-
beforeEach(() => {
vi.clearAllMocks();
- Object.defineProperty(navigator, "clipboard", {
- value: { writeText: mockWriteText },
- writable: true,
- configurable: true,
- });
+ });
+
+ it("copies all transcript turns when the Clipboard API is unavailable", async () => {
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
+ render();
+
+ await user.click(screen.getByRole("button", { name: /copy/i }));
+
+ expect(copyToClipboard).toHaveBeenCalledWith(
+ "assistant: Hello! How's your day going?\nassistant: I'm here to help with information and general questions.",
+ );
});
it("should render the component successfully", () => {
diff --git a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx
index 178875f1d2d..04f0a178462 100644
--- a/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx
+++ b/ui/litellm-dashboard/src/components/logs/detail/RealtimePrettyView.tsx
@@ -1,3 +1,4 @@
+import { copyToClipboard } from "@/utils/dataUtils";
/**
* RealtimePrettyView - Structured pretty view for OpenAI Realtime API logs
* Displays session config, conversation turns, and token usage
@@ -8,7 +9,6 @@ import { useState } from "react";
import { ChevronDown, ChevronUp, MessageSquare, Mic, Settings, Volume2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import { copyToClipboard } from "@/utils/dataUtils";
import { SectionHeader } from "./SectionHeader";
interface RealtimeEvent {
@@ -277,7 +277,7 @@ function ConversationCard({
(r.output || []).flatMap((o) => (o.content || []).map((c) => `${o.role}: ${c.transcript || c.text || ""}`)),
)
.join("\n");
- copyToClipboard(transcripts);
+ void copyToClipboard(transcripts);
};
return (
diff --git a/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx b/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx
index 713a1435da3..311b536d89c 100644
--- a/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx
+++ b/ui/litellm-dashboard/src/components/shared/CopyButton.test.tsx
@@ -13,13 +13,9 @@ vi.mock("lucide-react", async (importOriginal) => ({
Check: (props: ComponentProps<"svg">) => ,
}));
-const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
-
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
- if (clipboardDescriptor) Object.defineProperty(navigator, "clipboard", clipboardDescriptor);
- else Reflect.deleteProperty(navigator, "clipboard");
});
describe("CopyButton", () => {
@@ -28,10 +24,25 @@ describe("CopyButton", () => {
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
- it("does not show the checkmark when copying fails", async () => {
+ it("shows the icon confirmation only after copying succeeds", async () => {
+ const user = userEvent.setup();
+ const pendingCopy = Promise.withResolvers();
+ vi.mocked(copyToClipboard).mockReturnValue(pendingCopy.promise);
+ renderWithProviders();
+
+ await user.click(screen.getByRole("button", { name: "Copy value" }));
+
+ expect(copyToClipboard).toHaveBeenCalledWith("secret-value", "Value copied");
+ expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
+ expect(screen.queryByRole("img", { name: "Copied icon" })).not.toBeInTheDocument();
+
+ await act(async () => pendingCopy.resolve(true));
+ expect(screen.getByRole("img", { name: "Copied icon" })).toBeInTheDocument();
+ });
+
+ it("keeps the icon unchanged when copying fails", async () => {
const user = userEvent.setup();
vi.mocked(copyToClipboard).mockResolvedValue(false);
-
renderWithProviders();
await user.click(screen.getByRole("button", { name: "Copy value" }));
@@ -41,19 +52,6 @@ describe("CopyButton", () => {
expect(screen.getByRole("img", { name: "Copy icon" })).toBeInTheDocument();
});
- it("still copies when the clipboard API is unavailable, as on a plain HTTP origin", async () => {
- const user = userEvent.setup();
- Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
- vi.mocked(copyToClipboard).mockResolvedValue(true);
-
- renderWithProviders();
-
- await user.click(screen.getByRole("button", { name: "Copy value" }));
-
- expect(copyToClipboard).toHaveBeenCalledWith("secret-value", "Copied");
- expect(await screen.findByRole("img", { name: "Copied icon" })).toBeInTheDocument();
- });
-
it("keeps the default icon styling, custom classes, title and 1200ms confirmation", async () => {
vi.useFakeTimers();
vi.mocked(copyToClipboard).mockResolvedValue(true);
diff --git a/ui/litellm-dashboard/src/components/shared/CopyButton.tsx b/ui/litellm-dashboard/src/components/shared/CopyButton.tsx
index 89f99f1367b..225154e4bcf 100644
--- a/ui/litellm-dashboard/src/components/shared/CopyButton.tsx
+++ b/ui/litellm-dashboard/src/components/shared/CopyButton.tsx
@@ -53,7 +53,9 @@ const CopyButton: React.FC = ({
if (value == null || (!value && !isAction)) return null;
- const handleCopy = async () => setCopied(await copyToClipboard(value, copiedLabel));
+ const handleCopy = async () => {
+ setCopied(await copyToClipboard(value, copiedLabel));
+ };
const button = (