From ad1b48a008496ea7bd0bc259212c32605452b793 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 30 Jan 2026 16:02:58 -0800 Subject: [PATCH 1/6] Revert logs view commits to recreate with clean history (#20090) This reverts commits: - 437e9e23bd fix drawer - 61bb51dc3d complete v2 viewer - 2014bcf9d8 fixes ui - 5f07635310 fix ui - f07ef8af00 refactored code - 8b7a9250ce v0 - looks decen view Will create a new clean PR with the original changes. --- .../view_logs/CostBreakdownViewer.tsx | 2 +- .../LogDetailsDrawer/DrawerHeader.tsx | 214 -------- .../view_logs/LogDetailsDrawer/JsonViewer.tsx | 35 -- .../LogDetailsDrawer/LogDetailsDrawer.tsx | 505 ------------------ .../view_logs/LogDetailsDrawer/TokenFlow.tsx | 22 - .../LogDetailsDrawer/TruncatedValue.tsx | 35 -- .../LogDetailsDrawer/clipboardUtils.ts | 43 -- .../view_logs/LogDetailsDrawer/constants.ts | 44 -- .../view_logs/LogDetailsDrawer/index.ts | 2 - .../LogDetailsDrawer/useKeyboardNavigation.ts | 87 --- .../src/components/view_logs/columns.tsx | 40 ++ .../src/components/view_logs/index.tsx | 46 +- .../src/components/view_logs/table.tsx | 24 +- 13 files changed, 67 insertions(+), 1032 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index 7a02b89891b..affe28e0b25 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -59,7 +59,7 @@ export const CostBreakdownViewer: React.FC = ({ } return ( -
+
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx deleted file mode 100644 index 6aa6410b302..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { Button, Tag, Tooltip, Typography } from "antd"; -import { CloseOutlined, CopyOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; -import moment from "moment"; -import { LogEntry } from "../columns"; -import { getProviderLogoAndName } from "../../provider_info_helpers"; -import { - DRAWER_HEADER_PADDING, - COLOR_BORDER, - COLOR_BACKGROUND, - SPACING_MEDIUM, - SPACING_LARGE, - FONT_SIZE_HEADER, - FONT_SIZE_MEDIUM, - FONT_FAMILY_MONO, - SPACING_SMALL, -} from "./constants"; - -const { Text } = Typography; - -interface DrawerHeaderProps { - log: LogEntry; - onClose: () => void; - onCopyRequestId: () => void; - onPrevious: () => void; - onNext: () => void; - statusLabel: string; - statusColor: "error" | "success"; - environment: string; -} - -/** - * Header component for the log details drawer. - * Displays model/provider, request ID, navigation controls, status, environment, and timestamp. - */ -export function DrawerHeader({ - log, - onClose, - onCopyRequestId, - onPrevious, - onNext, - statusLabel, - statusColor, - environment, -}: DrawerHeaderProps) { - const provider = log.custom_llm_provider || ""; - const providerInfo = provider ? getProviderLogoAndName(provider) : null; - - return ( -
- {/* Row 0: Model + Provider with Logo */} - - - {/* Row 1: Request ID + Actions */} -
- - -
- - {/* Row 2: Status + Env + Timestamp */} - -
- ); -} - -/** - * Model and Provider display with logo - */ -function ModelProviderSection({ - model, - providerLogo, - providerName, -}: { - model: string; - providerLogo?: string; - providerName?: string; -}) { - return ( -
- {providerLogo && ( - {providerName { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - }} - /> - )} -
- - {model} - - {providerName && ( - - {providerName} - - )} -
-
- ); -} - -/** - * Request ID display with copy button - */ -function RequestIdSection({ requestId, onCopy }: { requestId: string; onCopy: () => void }) { - return ( -
- - - {requestId} - - - -
- ); -} - -/** - * Navigation controls (previous, next, close) - * Shows keyboard shortcuts styled as buttons for visibility - */ -function NavigationSection({ - onPrevious, - onNext, - onClose, -}: { - onPrevious: () => void; - onNext: () => void; - onClose: () => void; -}) { - const keyboardShortcutStyle: React.CSSProperties = { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - minWidth: "20px", - height: "20px", - padding: "0 6px", - fontSize: 11, - fontWeight: 600, - fontFamily: "monospace", - marginLeft: 4, - background: "#fff", - border: "1px solid #d9d9d9", - borderRadius: 4, - boxShadow: "0 1px 2px rgba(0,0,0,0.05)", - }; - - return ( -
- - - -
- - -
- ); -} - -/** - * Status bar with tags and timestamp - */ -function StatusBar({ - log, - statusLabel, - statusColor, - environment, -}: { - log: LogEntry; - statusLabel: string; - statusColor: "error" | "success"; - environment: string; -}) { - return ( -
- {statusLabel} - Env: {environment} - - {moment(log.startTime).format("MMM D, YYYY h:mm:ss A")} - ({moment(log.startTime).fromNow()}) - -
- ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx deleted file mode 100644 index 6463abf89d4..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Typography } from "antd"; -import { JsonView, defaultStyles } from "react-json-view-lite"; -import "react-json-view-lite/dist/index.css"; -import { JSON_MAX_HEIGHT, COLOR_BG_LIGHT, SPACING_LARGE } from "./constants"; - -const { Text } = Typography; - -interface JsonViewerProps { - data: any; - mode: "formatted"; -} - -/** - * Displays JSON data in formatted tree view. - * Uses an interactive tree component for easy navigation. - */ -export function JsonViewer({ data }: JsonViewerProps) { - if (!data) return No data; - - return ( -
-
- -
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx deleted file mode 100644 index 733cccdf1c9..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ /dev/null @@ -1,505 +0,0 @@ -import { useState } from "react"; -import { Drawer, Typography, Button, Descriptions, Card, Tag, Tabs, Alert, message } from "antd"; -import { CopyOutlined } from "@ant-design/icons"; -import { Accordion, AccordionHeader, AccordionBody } from "@tremor/react"; -import moment from "moment"; -import { LogEntry } from "../columns"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; -import GuardrailViewer from "../GuardrailViewer/GuardrailViewer"; -import { CostBreakdownViewer } from "../CostBreakdownViewer"; -import { ConfigInfoMessage } from "../ConfigInfoMessage"; -import { VectorStoreViewer } from "../VectorStoreViewer"; -import { TruncatedValue } from "./TruncatedValue"; -import { TokenFlow } from "./TokenFlow"; -import { JsonViewer } from "./JsonViewer"; -import { DrawerHeader } from "./DrawerHeader"; -import { copyToClipboard } from "./clipboardUtils"; -import { useKeyboardNavigation } from "./useKeyboardNavigation"; -import { - DRAWER_WIDTH, - DRAWER_CONTENT_PADDING, - API_BASE_MAX_WIDTH, - METADATA_MAX_HEIGHT, - TAB_REQUEST, - TAB_RESPONSE, - FONT_SIZE_SMALL, - FONT_FAMILY_MONO, - SPACING_XLARGE, - MESSAGE_REQUEST_ID_COPIED, -} from "./constants"; - -const { Text } = Typography; - -export interface LogDetailsDrawerProps { - open: boolean; - onClose: () => void; - logEntry: LogEntry | null; - onOpenSettings?: () => void; - allLogs?: LogEntry[]; - onSelectLog?: (log: LogEntry) => void; -} - -/** - * Right-side drawer panel for displaying detailed log information. - * Features: - * - Request ID prominently displayed with copy functionality - * - Keyboard navigation (J/K for next/prev, Escape to close) - * - Formatted and JSON view toggle for request/response - * - Smart display of cache fields (hidden when zero) - * - Error alerts for failed requests - * - Collapsible sections for guardrails, vector store, metadata - */ -export function LogDetailsDrawer({ - open, - onClose, - logEntry, - onOpenSettings, - allLogs = [], - onSelectLog, -}: LogDetailsDrawerProps) { - const [activeTab, setActiveTab] = useState(TAB_REQUEST); - - // Keyboard navigation - const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({ - isOpen: open, - currentLog: logEntry, - allLogs, - onClose, - onSelectLog, - }); - - if (!logEntry) return null; - - const metadata = logEntry.metadata || {}; - const hasError = metadata.status === "failure"; - const errorInfo = hasError ? metadata.error_information : null; - - // Check if request/response data is present - const hasMessages = checkHasMessages(logEntry.messages); - const hasResponse = checkHasResponse(logEntry.response); - const missingData = !hasMessages && !hasResponse; - - // Guardrail data - const guardrailInfo = metadata?.guardrail_information; - const guardrailEntries = normalizeGuardrailEntries(guardrailInfo); - const hasGuardrailData = guardrailEntries.length > 0; - const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries); - const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries); - - // Vector store data - const hasVectorStoreData = checkHasVectorStoreData(metadata); - - // Status display values - const statusLabel = metadata.status === "failure" ? "Failure" : "Success"; - const statusColor = metadata.status === "failure" ? ("error" as const) : ("success" as const); - const environment = metadata?.user_api_key_team_alias || "default"; - - const handleCopyRequestId = () => { - navigator.clipboard.writeText(logEntry.request_id); - message.success(MESSAGE_REQUEST_ID_COPIED); - }; - - const getRawRequest = () => { - return formatData(logEntry.proxy_server_request || logEntry.messages); - }; - - const getFormattedResponse = () => { - if (hasError && errorInfo) { - return { - error: { - message: errorInfo.error_message || "An error occurred", - type: errorInfo.error_class || "error", - code: errorInfo.error_code || "unknown", - param: null, - }, - }; - } - return formatData(logEntry.response); - }; - - return ( - - - -
- {/* Error Alert - Show prominently at top for failures */} - {hasError && errorInfo && ( - } - className="mb-6" - /> - )} - - {/* Tags - Only show if present */} - {logEntry.request_tags && Object.keys(logEntry.request_tags).length > 0 && ( - - )} - - {/* Request Details Section */} -
- - - {logEntry.model} - {logEntry.custom_llm_provider || "-"} - {logEntry.call_type} - - - - - - - {logEntry.requester_ip_address && ( - {logEntry.requester_ip_address} - )} - {hasGuardrailData && ( - - - - )} - - -
- - {/* Metrics Section */} - - - {/* Cost Breakdown - Show if cost breakdown data is available */} - - - {/* Configuration Info Message - Show when data is missing */} - {missingData && ( -
- -
- )} - - {/* Request/Response JSON - Collapsible */} - copyToClipboard(JSON.stringify(data, null, 2), label)} - getRawRequest={getRawRequest} - getFormattedResponse={getFormattedResponse} - /> - - {/* Guardrail Data - Show only if present */} - {hasGuardrailData && } - - {/* Vector Store Request Data - Show only if present */} - {hasVectorStoreData && } - - {/* Metadata Card - Only show if there's metadata */} - {logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && ( - copyToClipboard(data, "Metadata")} /> - )} - - {/* Bottom spacing for scroll area */} -
-
- - ); -} - -// ============================================================================ -// Helper Components -// ============================================================================ - -function ErrorDescription({ errorInfo }: { errorInfo: any }) { - return ( -
- {errorInfo.error_code && ( -
- Error Code: {errorInfo.error_code} -
- )} - {errorInfo.error_message && ( -
- Message: {errorInfo.error_message} -
- )} -
- ); -} - -function TagsSection({ tags }: { tags: Record }) { - return ( -
- - Tags - -
- {Object.entries(tags).map(([key, value]) => ( - - {key}: {String(value)} - - ))} -
-
- ); -} - -function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) { - return ( - <> - {label} - {maskedCount > 0 && ( - - {maskedCount} masked - - )} - - ); -} - -function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata: Record }) { - const hasCacheActivity = - logEntry.cache_hit || - (metadata?.additional_usage_values?.cache_read_input_tokens && - metadata.additional_usage_values.cache_read_input_tokens > 0); - - return ( -
- - - - - - ${formatNumberWithCommas(logEntry.spend || 0, 8)} - {logEntry.duration?.toFixed(3)} s - - {/* Only show cache fields if there's cache activity */} - {hasCacheActivity && ( - <> - - {logEntry.cache_hit || "None"} - - {metadata?.additional_usage_values?.cache_read_input_tokens > 0 && ( - - {formatNumberWithCommas(metadata.additional_usage_values.cache_read_input_tokens)} - - )} - {metadata?.additional_usage_values?.cache_creation_input_tokens > 0 && ( - - {formatNumberWithCommas(metadata.additional_usage_values.cache_creation_input_tokens)} - - )} - - )} - - {metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && ( - - {metadata.litellm_overhead_time_ms.toFixed(2)} ms - - )} - - - {moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} - - - {moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")} - - - -
- ); -} - -interface RequestResponseSectionProps { - hasResponse: boolean; - onCopy: (data: any, label: string) => void; - getRawRequest: () => any; - getFormattedResponse: () => any; -} - -function RequestResponseSection({ - hasResponse, - onCopy, - getRawRequest, - getFormattedResponse, -}: RequestResponseSectionProps) { - const [activeTab, setActiveTab] = useState(TAB_REQUEST); - - const handleCopy = () => { - const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse(); - const label = activeTab === TAB_REQUEST ? "Request" : "Response"; - onCopy(data, label); - }; - - return ( -
- - -

Request & Response

-
- -
- setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)} - tabBarExtraContent={ - - } - items={[ - { - key: TAB_REQUEST, - label: "Request", - children: ( -
- -
- ), - }, - { - key: TAB_RESPONSE, - label: "Response", - children: ( -
- {hasResponse ? ( - - ) : ( -
- Response data not available -
- )} -
- ), - }, - ]} - /> -
-
-
-
- ); -} - -function MetadataSection({ metadata, onCopy }: { metadata: Record; onCopy: (data: string) => void }) { - return ( -
- } - onClick={() => onCopy(JSON.stringify(metadata, null, 2))} - > - Copy - - } - > -
-          {JSON.stringify(metadata, null, 2)}
-        
-
-
- ); -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function formatData(input: any) { - if (typeof input === "string") { - try { - return JSON.parse(input); - } catch { - return input; - } - } - return input; -} - -function checkHasMessages(messages: any): boolean { - if (!messages) return false; - if (Array.isArray(messages)) return messages.length > 0; - if (typeof messages === "object") return Object.keys(messages).length > 0; - return false; -} - -function checkHasResponse(response: any): boolean { - if (!response) return false; - return Object.keys(formatData(response)).length > 0; -} - -function normalizeGuardrailEntries(guardrailInfo: any): any[] { - if (Array.isArray(guardrailInfo)) return guardrailInfo; - if (guardrailInfo) return [guardrailInfo]; - return []; -} - -function calculateTotalMaskedEntities(entries: any[]): number { - return entries.reduce((sum, entry) => { - const maskedCounts = entry?.masked_entity_count; - if (!maskedCounts) return sum; - return ( - sum + - Object.values(maskedCounts).reduce((acc, count) => (typeof count === "number" ? acc + count : acc), 0) - ); - }, 0); -} - -function getGuardrailLabel(entries: any[]): string { - if (entries.length === 0) return "-"; - if (entries.length === 1) return entries[0]?.guardrail_name ?? "-"; - return `${entries.length} guardrails`; -} - -function checkHasVectorStoreData(metadata: Record): boolean { - return ( - metadata.vector_store_request_metadata && - Array.isArray(metadata.vector_store_request_metadata) && - metadata.vector_store_request_metadata.length > 0 - ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx deleted file mode 100644 index 5eec3c0a5cb..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Typography } from "antd"; - -const { Text } = Typography; - -interface TokenFlowProps { - prompt?: number; - completion?: number; - total?: number; -} - -/** - * Displays token usage in LiteLLM format: "12 (9 prompt tokens + 3 completion tokens)" - * Shows total with breakdown of prompt and completion tokens. - */ -export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) { - return ( - - {total.toLocaleString()} ({prompt.toLocaleString()} prompt tokens + {completion.toLocaleString()} completion - tokens) - - ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx deleted file mode 100644 index b03895808d6..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Typography, Tooltip } from "antd"; -import { DEFAULT_MAX_WIDTH, FONT_FAMILY_MONO, FONT_SIZE_SMALL } from "./constants"; - -const { Text } = Typography; - -interface TruncatedValueProps { - value?: string; - maxWidth?: number; -} - -/** - * Displays a truncated value with tooltip and copy functionality. - * Useful for displaying long IDs, URLs, or other text that may overflow. - */ -export function TruncatedValue({ value, maxWidth = DEFAULT_MAX_WIDTH }: TruncatedValueProps) { - if (!value) return -; - - return ( - - - {value} - - - ); -} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts deleted file mode 100644 index 6aae95bb72c..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { message } from "antd"; -import { MESSAGE_COPY_SUCCESS } from "./constants"; - -/** - * Copies text to clipboard with fallback for non-secure contexts. - * Shows success/error message to user. - * - * @param text - Text to copy to clipboard - * @param label - Label for the copied content (e.g., "Request", "Metadata") - * @returns Promise - true if copy succeeded, false otherwise - */ -export async function copyToClipboard(text: string, label: string): Promise { - try { - // Try modern clipboard API first - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - message.success(`${label} ${MESSAGE_COPY_SUCCESS}`); - return true; - } else { - // Fallback for non-secure contexts (like 0.0.0.0) - const textArea = document.createElement("textarea"); - textArea.value = text; - textArea.style.position = "fixed"; - textArea.style.opacity = "0"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - const successful = document.execCommand("copy"); - document.body.removeChild(textArea); - - if (!successful) { - throw new Error("execCommand failed"); - } - message.success(`${label} ${MESSAGE_COPY_SUCCESS}`); - return true; - } - } catch (error) { - console.error("Copy failed:", error); - message.error(`Failed to copy ${label}`); - return false; - } -} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts deleted file mode 100644 index 91f5ff8f118..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Drawer configuration constants -export const DRAWER_WIDTH = "60%"; -export const DRAWER_HEADER_PADDING = "16px 24px"; -export const DRAWER_CONTENT_PADDING = "24px"; - -// Truncation and display limits -export const DEFAULT_MAX_WIDTH = 180; -export const API_BASE_MAX_WIDTH = 200; -export const JSON_MAX_HEIGHT = 400; -export const METADATA_MAX_HEIGHT = 300; - -// Tab keys (kept for backwards compatibility if needed) -export const TAB_REQUEST = "request" as const; -export const TAB_RESPONSE = "response" as const; - -// Keyboard shortcuts -export const KEY_ESCAPE = "Escape"; -export const KEY_J_LOWER = "j"; -export const KEY_J_UPPER = "J"; -export const KEY_K_LOWER = "k"; -export const KEY_K_UPPER = "K"; - -// Typography -export const FONT_FAMILY_MONO = "monospace"; -export const FONT_SIZE_SMALL = 12; -export const FONT_SIZE_MEDIUM = 13; -export const FONT_SIZE_HEADER = 16; - -// Colors -export const COLOR_BORDER = "#f0f0f0"; -export const COLOR_BACKGROUND = "#fff"; -export const COLOR_SECONDARY = "#8c8c8c"; -export const COLOR_BG_LIGHT = "#fafafa"; - -// Spacing -export const SPACING_SMALL = 4; -export const SPACING_MEDIUM = 8; -export const SPACING_LARGE = 12; -export const SPACING_XLARGE = 16; -export const SPACING_XXLARGE = 24; - -// Messages -export const MESSAGE_COPY_SUCCESS = "copied to clipboard"; -export const MESSAGE_REQUEST_ID_COPIED = "Request ID copied to clipboard"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts deleted file mode 100644 index e1fdd9d2d60..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LogDetailsDrawer } from "./LogDetailsDrawer"; -export type { LogDetailsDrawerProps } from "./LogDetailsDrawer"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts deleted file mode 100644 index e4fa9bc6185..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect } from "react"; -import { LogEntry } from "../columns"; -import { KEY_ESCAPE, KEY_J_LOWER, KEY_J_UPPER, KEY_K_LOWER, KEY_K_UPPER } from "./constants"; - -interface UseKeyboardNavigationProps { - isOpen: boolean; - currentLog: LogEntry | null; - allLogs: LogEntry[]; - onClose: () => void; - onSelectLog?: (log: LogEntry) => void; -} - -/** - * Custom hook for keyboard navigation in the log details drawer. - * Handles J/K for next/previous and Escape for close. - * - * Keyboard shortcuts: - * - J: Navigate to next log - * - K: Navigate to previous log - * - Escape: Close drawer - */ -export function useKeyboardNavigation({ - isOpen, - currentLog, - allLogs, - onClose, - onSelectLog, -}: UseKeyboardNavigationProps) { - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Don't trigger if user is typing in an input - if (isUserTyping(e.target)) { - return; - } - - if (!isOpen) return; - - switch (e.key) { - case KEY_ESCAPE: - onClose(); - break; - case KEY_J_LOWER: - case KEY_J_UPPER: - selectNextLog(); - break; - case KEY_K_LOWER: - case KEY_K_UPPER: - selectPreviousLog(); - break; - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isOpen, currentLog, allLogs]); - - const selectNextLog = () => { - if (!currentLog || !allLogs.length || !onSelectLog) return; - - const currentIndex = allLogs.findIndex((l) => l.request_id === currentLog.request_id); - if (currentIndex < allLogs.length - 1) { - onSelectLog(allLogs[currentIndex + 1]); - } - }; - - const selectPreviousLog = () => { - if (!currentLog || !allLogs.length || !onSelectLog) return; - - const currentIndex = allLogs.findIndex((l) => l.request_id === currentLog.request_id); - if (currentIndex > 0) { - onSelectLog(allLogs[currentIndex - 1]); - } - }; - - return { - selectNextLog, - selectPreviousLog, - }; -} - -/** - * Checks if the user is currently typing in an input field. - * Used to prevent keyboard shortcuts from interfering with text input. - */ -function isUserTyping(target: EventTarget | null): boolean { - return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; -} diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 3e72c8e13b8..2da1e83747b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -49,6 +49,46 @@ export type LogEntry = { }; export const columns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + // Convert the cell function to a React component to properly use hooks + const ExpanderCell = () => { + const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded()); + + // Memoize the toggle handler to prevent unnecessary re-renders + const toggleHandler = React.useCallback(() => { + setLocalExpanded((prev) => !prev); + row.getToggleExpandedHandler()(); + }, [row]); + + return row.getCanExpand() ? ( + + ) : ( + + ); + }; + + // Return the component + return ; + }, + }, { header: "Time", accessorKey: "startTime", diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 3859a5e51fb..826fc7ccc02 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -31,7 +31,6 @@ import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsMo import { DataTable } from "./table"; import { VectorStoreViewer } from "./VectorStoreViewer"; import NewBadge from "../common_components/NewBadge"; -import { LogDetailsDrawer } from "./LogDetailsDrawer"; interface SpendLogsTableProps { accessToken: string | null; @@ -90,8 +89,7 @@ export default function SpendLogsTable({ const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole)); const [activeTab, setActiveTab] = useState("request logs"); - const [selectedLog, setSelectedLog] = useState(null); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [expandedRequestId, setExpandedRequestId] = useState(null); const [selectedSessionId, setSelectedSessionId] = useState(null); const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false); @@ -319,6 +317,17 @@ export default function SpendLogsTable({ enabled: !!accessToken && !!selectedSessionId, }); + // Add this effect to preserve expanded state when data refreshes + useEffect(() => { + if (logs.data?.data && expandedRequestId) { + // Check if the expanded request ID still exists in the new data + const stillExists = logs.data.data.some((log) => log.request_id === expandedRequestId); + if (!stillExists) { + // If the request ID no longer exists in the data, clear the expanded state + setExpandedRequestId(null); + } + } + }, [logs.data?.data, expandedRequestId]); if (!accessToken || !token || !userRole || !userID) { return null; @@ -358,18 +367,8 @@ export default function SpendLogsTable({ logs.refetch(); }; - const handleRowClick = (log: LogEntry) => { - setSelectedLog(log); - setIsDrawerOpen(true); - }; - - const handleCloseDrawer = () => { - setIsDrawerOpen(false); - // Optionally keep selectedLog for animation purposes - }; - - const handleSelectLog = (log: LogEntry) => { - setSelectedLog(log); + const handleRowExpand = (requestId: string | null) => { + setExpandedRequestId(requestId); }; // Function to extract unique error codes from logs @@ -555,7 +554,9 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} + getRowCanExpand={() => true} + // Optionally: add session-specific row expansion state />
) : ( @@ -752,7 +753,8 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} + getRowCanExpand={() => true} />
@@ -773,16 +775,6 @@ export default function SpendLogsTable({ - - {/* Log Details Drawer */} - setIsSpendLogsSettingsModalVisible(true)} - allLogs={filteredData} - onSelectLog={handleSelectLog} - />
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index fb7706cba19..605341cb2ed 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -6,10 +6,8 @@ import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } fro interface DataTableProps { data: TData[]; columns: ColumnDef[]; - onRowClick?: (row: TData) => void; - // Legacy props for backward compatibility (audit logs) - renderSubComponent?: (props: { row: Row }) => React.ReactElement; - getRowCanExpand?: (row: Row) => boolean; + renderSubComponent: (props: { row: Row }) => React.ReactElement; + getRowCanExpand: (row: Row) => boolean; isLoading?: boolean; loadingMessage?: string; noDataMessage?: string; @@ -18,26 +16,22 @@ interface DataTableProps { export function DataTable({ data = [], columns, - onRowClick, - renderSubComponent, getRowCanExpand, + renderSubComponent, isLoading = false, loadingMessage = "🚅 Loading logs...", noDataMessage = "No logs found", }: DataTableProps) { - // Determine if we're in legacy expansion mode or new drawer mode - const isLegacyMode = !!renderSubComponent && !!getRowCanExpand; - const table = useReactTable({ data, columns, - ...(isLegacyMode && { getRowCanExpand }), + getRowCanExpand, getRowId: (row: TData, index: number) => { const _row: any = row as any; return _row?.request_id ?? String(index); }, getCoreRowModel: getCoreRowModel(), - ...(isLegacyMode && { getExpandedRowModel: getExpandedRowModel() }), + getExpandedRowModel: getExpandedRowModel(), }); return ( @@ -68,10 +62,7 @@ export function DataTable({ ) : table.getRowModel().rows.length > 0 ? ( table.getRowModel().rows.map((row) => ( - !isLegacyMode && onRowClick?.(row.original)} - > + {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -79,8 +70,7 @@ export function DataTable({ ))} - {/* Legacy expansion mode for audit logs */} - {isLegacyMode && row.getIsExpanded() && renderSubComponent && ( + {row.getIsExpanded() && (
{renderSubComponent({ row })}
From 3e8e1ded54b6074e4b6b7e0954d18f75af103cfc Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 30 Jan 2026 16:35:24 -0800 Subject: [PATCH 2/6] update image and bounded logo in navbar --- litellm/proxy/cached_logo.jpg | Bin 50535 -> 24694 bytes .../src/components/navbar.tsx | 46 +++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/cached_logo.jpg b/litellm/proxy/cached_logo.jpg index da4faf5b5ca6c812a73e3c1a74d93ea6f6afa682..a10a1d249690c6148a6ba40893e7c0ada44adec0 100644 GIT binary patch literal 24694 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�R2V*J6MXq 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 F{u}R=q(uM# literal 50535 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%0DeNB?(iW!XDl3Is3M{ws&87hHz5Vz*`;sLjzL^ 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 QPXGV_07*qoM6N<$f~<5rQUCw| diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 8e4fda0ba52..fca5f7e656c 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -24,7 +24,7 @@ import { UserOutlined, } from "@ant-design/icons"; import type { MenuProps } from "antd"; -import { Button, Dropdown, Switch, Tooltip } from "antd"; +import { Button, Dropdown, Switch, Tag, Tooltip } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; @@ -210,28 +210,38 @@ const Navbar: React.FC = ({ )} -
+
- LiteLLM Brand - - ❄️ - +
+ LiteLLM Brand +
{version && ( - - v{version} - +
+ + ❄️ + + + + v{version} + + +
)}
From 0942f98b4d6d815a5e148c5245731bf7a9e311d1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 30 Jan 2026 17:18:06 -0800 Subject: [PATCH 3/6] refactoring user dropdown --- .../Navbar/UserDropdown/UserDropdown.test.tsx | 289 ++++++++++++++++++ .../Navbar/UserDropdown/UserDropdown.tsx | 161 ++++++++++ .../src/components/navbar.test.tsx | 39 ++- .../src/components/navbar.tsx | 145 +-------- 4 files changed, 487 insertions(+), 147 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx new file mode 100644 index 00000000000..de853303c15 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx @@ -0,0 +1,289 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; +import UserDropdown from "./UserDropdown"; + +let mockUseAuthorizedImpl = () => ({ + userId: "test-user-id", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: false, +}); + +let mockUseDisableShowPromptsImpl = () => false; + +let mockGetLocalStorageItemImpl = (key: string): string | null => { + if (key === "disableShowNewBadge") return null; + if (key === "disableShowPrompts") return null; + return null; +}; + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorizedImpl(), +})); + +vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ + useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(), +})); + +vi.mock("@/utils/localStorageUtils", () => ({ + LOCAL_STORAGE_EVENT: "local-storage-change", + getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key), + setLocalStorageItem: vi.fn(), + removeLocalStorageItem: vi.fn(), + emitLocalStorageChange: vi.fn(), +})); + +describe("UserDropdown", () => { + const mockOnLogout = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorizedImpl = () => ({ + userId: "test-user-id", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: false, + }); + mockUseDisableShowPromptsImpl = () => false; + mockGetLocalStorageItemImpl = (key: string): string | null => { + if (key === "disableShowNewBadge") return null; + if (key === "disableShowPrompts") return null; + return null; + }; + }); + + it("should render", () => { + renderWithProviders(); + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("should display user button with User text", () => { + renderWithProviders(); + expect(screen.getByText("User")).toBeInTheDocument(); + }); + + it("should show user email when dropdown is opened", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + }); + + it("should show user ID when dropdown is opened", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test-user-id")).toBeInTheDocument(); + }); + }); + + it("should show user role when dropdown is opened", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + }); + + it("should display Standard badge for non-premium users", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("Standard")).toBeInTheDocument(); + }); + }); + + it("should display Premium badge for premium users", async () => { + const user = userEvent.setup(); + mockUseAuthorizedImpl = () => ({ + userId: "test-user-id", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: true, + }); + + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("Premium")).toBeInTheDocument(); + }); + }); + + it("should call onLogout when logout is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Logout")); + + expect(mockOnLogout).toHaveBeenCalledTimes(1); + }); + + it("should toggle hide new feature indicators switch", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const toggle = screen.getByLabelText("Toggle hide new feature indicators"); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + + const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); + expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true"); + expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge"); + }); + + it("should toggle hide new feature indicators switch off", async () => { + const user = userEvent.setup(); + mockGetLocalStorageItemImpl = (key: string): string | null => { + if (key === "disableShowNewBadge") return "true"; + return null; + }; + + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const toggle = screen.getByLabelText("Toggle hide new feature indicators"); + expect(toggle).toBeChecked(); + + await user.click(toggle); + + const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); + expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge"); + expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge"); + }); + + it("should toggle hide all prompts switch", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const toggle = screen.getByLabelText("Toggle hide all prompts"); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + + const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); + expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts", "true"); + expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts"); + }); + + it("should toggle hide all prompts switch off", async () => { + const user = userEvent.setup(); + mockUseDisableShowPromptsImpl = () => true; + mockGetLocalStorageItemImpl = (key: string): string | null => { + if (key === "disableShowPrompts") return "true"; + return null; + }; + + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const toggle = screen.getByLabelText("Toggle hide all prompts"); + expect(toggle).toBeChecked(); + + await user.click(toggle); + + const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); + expect(localStorageUtils.removeLocalStorageItem).toHaveBeenCalledWith("disableShowPrompts"); + expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts"); + }); + + it("should display dash when user email is not available", async () => { + const user = userEvent.setup(); + mockUseAuthorizedImpl = () => ({ + userId: "test-user-id", + userEmail: null as any, + userRole: "Admin", + premiumUser: false, + }); + + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("-")).toBeInTheDocument(); + }); + }); + + it("should display dash when user ID is not available", async () => { + const user = userEvent.setup(); + mockUseAuthorizedImpl = () => ({ + userId: null as any, + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: false, + }); + + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + const dashElements = screen.getAllByText("-"); + expect(dashElements.length).toBeGreaterThan(0); + }); + }); + + it("should initialize hide new feature indicators from localStorage", async () => { + const user = userEvent.setup(); + mockGetLocalStorageItemImpl = (key: string): string | null => { + if (key === "disableShowNewBadge") return "true"; + return null; + }; + + renderWithProviders(); + + await user.click(screen.getByText("User")); + + await waitFor(() => { + expect(screen.getByText("test@example.com")).toBeInTheDocument(); + }); + + const toggle = screen.getByLabelText("Toggle hide new feature indicators"); + expect(toggle).toBeChecked(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx new file mode 100644 index 00000000000..f80af33f9e2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -0,0 +1,161 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { + emitLocalStorageChange, + getLocalStorageItem, + removeLocalStorageItem, + setLocalStorageItem, +} from "@/utils/localStorageUtils"; +import { + CrownOutlined, + DownOutlined, + LogoutOutlined, + MailOutlined, + SafetyOutlined, + UserOutlined, +} from "@ant-design/icons"; +import type { MenuProps } from "antd"; +import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; +import React, { useEffect, useState } from "react"; + +const { Text } = Typography; + +interface UserDropdownProps { + onLogout: () => void; +} + +const UserDropdown: React.FC = ({ onLogout }) => { + const { userId, userEmail, userRole, premiumUser } = useAuthorized(); + const disableShowPrompts = useDisableShowPrompts(); + const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); + + useEffect(() => { + const storedValue = getLocalStorageItem("disableShowNewBadge"); + setDisableShowNewBadge(storedValue === "true"); + }, []); + + const userItems: MenuProps["items"] = [ + { + key: "logout", + label: ( + + + Logout + + ), + onClick: onLogout, + }, + ]; + + const renderUserInfoSection = () => ( + + + + + {userEmail || "-"} + + {premiumUser ? ( + } + color="gold" + > + Premium + + ) : ( + + } + > + Standard + + + )} + + + + + + User ID + + + {userId || "-"} + + + + + + Role + + {userRole} + + + + Hide New Feature Indicators + { + setDisableShowNewBadge(checked); + if (checked) { + setLocalStorageItem("disableShowNewBadge", "true"); + emitLocalStorageChange("disableShowNewBadge"); + } else { + removeLocalStorageItem("disableShowNewBadge"); + emitLocalStorageChange("disableShowNewBadge"); + } + }} + aria-label="Toggle hide new feature indicators" + /> + + + Hide All Prompts + { + if (checked) { + setLocalStorageItem("disableShowPrompts", "true"); + emitLocalStorageChange("disableShowPrompts"); + } else { + removeLocalStorageItem("disableShowPrompts"); + emitLocalStorageChange("disableShowPrompts"); + } + }} + aria-label="Toggle hide all prompts" + /> + + + ); + + return ( + ( +
+ {renderUserInfoSection()} + + {React.cloneElement(menu as React.ReactElement, { + style: { boxShadow: "none" }, + })} +
+ )} + > + +
+ ); +}; + +export default UserDropdown; diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index 9fa32cf9cb0..a2996f70587 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -15,8 +15,14 @@ vi.mock("@/utils/proxyUtils", () => ({ // Create mock functions that can be controlled in tests let mockUseThemeImpl = () => ({ logoUrl: null as string | null }); let mockUseHealthReadinessImpl = () => ({ data: null as any }); -let mockGetLocalStorageItemImpl = () => null as string | null; +let mockGetLocalStorageItemImpl = (key: string) => null as string | null; let mockUseDisableShowPromptsImpl = () => false; +let mockUseAuthorizedImpl = () => ({ + userId: "test-user", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: false, +}); vi.mock("@/contexts/ThemeContext", () => ({ useTheme: () => mockUseThemeImpl(), @@ -30,9 +36,13 @@ vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ useDisableShowPrompts: () => mockUseDisableShowPromptsImpl(), })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorizedImpl(), +})); + vi.mock("@/utils/localStorageUtils", () => ({ LOCAL_STORAGE_EVENT: "local-storage-change", - getLocalStorageItem: () => mockGetLocalStorageItemImpl(), + getLocalStorageItem: (key: string) => mockGetLocalStorageItemImpl(key), setLocalStorageItem: vi.fn(), removeLocalStorageItem: vi.fn(), emitLocalStorageChange: vi.fn(), @@ -123,14 +133,27 @@ describe("Navbar", () => { it("should show premium user badge when premiumUser is true", async () => { const user = userEvent.setup(); - const premiumProps = { ...defaultProps, premiumUser: true }; - renderWithProviders(); + mockUseAuthorizedImpl = () => ({ + userId: "test-user", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: true, + }); + renderWithProviders(); await user.click(screen.getByText("User")); await waitFor(() => { expect(screen.getByText("Premium")).toBeInTheDocument(); }); + + // Reset mock + mockUseAuthorizedImpl = () => ({ + userId: "test-user", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: false, + }); }); it("should show version badge when health data contains version", () => { @@ -167,7 +190,10 @@ describe("Navbar", () => { const user = userEvent.setup(); // Initially disabled - mockGetLocalStorageItemImpl = () => "false"; + mockGetLocalStorageItemImpl = (key: string) => { + if (key === "disableShowNewBadge") return "false"; + return null; + }; renderWithProviders(); @@ -186,6 +212,9 @@ describe("Navbar", () => { const localStorageUtils = vi.mocked(await import("@/utils/localStorageUtils")); expect(localStorageUtils.setLocalStorageItem).toHaveBeenCalledWith("disableShowNewBadge", "true"); expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowNewBadge"); + + // Reset mock + mockGetLocalStorageItemImpl = (key: string) => null; }); it("should handle logout functionality", async () => { diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index fca5f7e656c..3649ca76238 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,32 +1,20 @@ import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness"; -import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; -import { - emitLocalStorageChange, - getLocalStorageItem, - removeLocalStorageItem, - setLocalStorageItem, -} from "@/utils/localStorageUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; import { - CrownOutlined, GithubOutlined, - LogoutOutlined, - MailOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, - SafetyOutlined, SlackOutlined, SunOutlined, - UserOutlined, } from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { Button, Dropdown, Switch, Tag, Tooltip } from "antd"; +import { Button, Switch, Tag } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; +import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; interface NavbarProps { userID: string | null; @@ -59,8 +47,6 @@ const Navbar: React.FC = ({ }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); - const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); - const disableShowPrompts = useDisableShowPrompts(); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadiness(); const version = healthData?.litellm_version; @@ -82,11 +68,6 @@ const Navbar: React.FC = ({ initializeProxySettings(); }, [accessToken]); - useEffect(() => { - const storedValue = getLocalStorageItem("disableShowNewBadge"); - setDisableShowNewBadge(storedValue === "true"); - }, []); - useEffect(() => { setLogoutUrl(proxySettings?.PROXY_LOGOUT_URL || ""); }, [proxySettings]); @@ -96,105 +77,6 @@ const Navbar: React.FC = ({ window.location.href = logoutUrl; }; - const userItems: MenuProps["items"] = [ - { - key: "user-info", - // Prevent dropdown from closing when interacting with the toggle - onClick: (info) => info.domEvent?.stopPropagation(), - label: ( -
-
-
- - {userID} -
- {premiumUser ? ( - -
- - Premium -
-
- ) : ( - -
- - Standard -
-
- )} -
-
-
- - Role - {userRole} -
-
- - Email - - {userEmail || "Unknown"} - -
-
e.stopPropagation()} - > - Hide New Feature Indicators - { - setDisableShowNewBadge(checked); - if (checked) { - setLocalStorageItem("disableShowNewBadge", "true"); - emitLocalStorageChange("disableShowNewBadge"); - } else { - removeLocalStorageItem("disableShowNewBadge"); - emitLocalStorageChange("disableShowNewBadge"); - } - }} - aria-label="Toggle hide new feature indicators" - /> -
-
e.stopPropagation()} - > - Hide All Prompts - { - if (checked) { - setLocalStorageItem("disableShowPrompts", "true"); - emitLocalStorageChange("disableShowPrompts"); - } else { - removeLocalStorageItem("disableShowPrompts"); - emitLocalStorageChange("disableShowPrompts"); - } - }} - aria-label="Toggle hide all prompts" - /> -
-
-
- ), - }, - { - key: "logout", - label: ( -
- - Logout -
- ), - }, - ]; - return (
From 2ce87a3d623e0cf072308554dc522ffe2440a997 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 30 Jan 2026 18:17:45 -0800 Subject: [PATCH 4/6] new utils --- .../LogDetailsDrawer/clipboardUtils.ts | 43 +++++++++ .../view_logs/LogDetailsDrawer/constants.ts | 44 +++++++++ .../view_logs/LogDetailsDrawer/utils.ts | 93 +++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts new file mode 100644 index 00000000000..6aae95bb72c --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts @@ -0,0 +1,43 @@ +import { message } from "antd"; +import { MESSAGE_COPY_SUCCESS } from "./constants"; + +/** + * Copies text to clipboard with fallback for non-secure contexts. + * Shows success/error message to user. + * + * @param text - Text to copy to clipboard + * @param label - Label for the copied content (e.g., "Request", "Metadata") + * @returns Promise - true if copy succeeded, false otherwise + */ +export async function copyToClipboard(text: string, label: string): Promise { + try { + // Try modern clipboard API first + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + message.success(`${label} ${MESSAGE_COPY_SUCCESS}`); + return true; + } else { + // Fallback for non-secure contexts (like 0.0.0.0) + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand("copy"); + document.body.removeChild(textArea); + + if (!successful) { + throw new Error("execCommand failed"); + } + message.success(`${label} ${MESSAGE_COPY_SUCCESS}`); + return true; + } + } catch (error) { + console.error("Copy failed:", error); + message.error(`Failed to copy ${label}`); + return false; + } +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts new file mode 100644 index 00000000000..91f5ff8f118 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/constants.ts @@ -0,0 +1,44 @@ +// Drawer configuration constants +export const DRAWER_WIDTH = "60%"; +export const DRAWER_HEADER_PADDING = "16px 24px"; +export const DRAWER_CONTENT_PADDING = "24px"; + +// Truncation and display limits +export const DEFAULT_MAX_WIDTH = 180; +export const API_BASE_MAX_WIDTH = 200; +export const JSON_MAX_HEIGHT = 400; +export const METADATA_MAX_HEIGHT = 300; + +// Tab keys (kept for backwards compatibility if needed) +export const TAB_REQUEST = "request" as const; +export const TAB_RESPONSE = "response" as const; + +// Keyboard shortcuts +export const KEY_ESCAPE = "Escape"; +export const KEY_J_LOWER = "j"; +export const KEY_J_UPPER = "J"; +export const KEY_K_LOWER = "k"; +export const KEY_K_UPPER = "K"; + +// Typography +export const FONT_FAMILY_MONO = "monospace"; +export const FONT_SIZE_SMALL = 12; +export const FONT_SIZE_MEDIUM = 13; +export const FONT_SIZE_HEADER = 16; + +// Colors +export const COLOR_BORDER = "#f0f0f0"; +export const COLOR_BACKGROUND = "#fff"; +export const COLOR_SECONDARY = "#8c8c8c"; +export const COLOR_BG_LIGHT = "#fafafa"; + +// Spacing +export const SPACING_SMALL = 4; +export const SPACING_MEDIUM = 8; +export const SPACING_LARGE = 12; +export const SPACING_XLARGE = 16; +export const SPACING_XXLARGE = 24; + +// Messages +export const MESSAGE_COPY_SUCCESS = "copied to clipboard"; +export const MESSAGE_REQUEST_ID_COPIED = "Request ID copied to clipboard"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts new file mode 100644 index 00000000000..61301cf5b54 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/utils.ts @@ -0,0 +1,93 @@ +/** + * Utility functions for LogDetailsDrawer component. + * These functions handle data formatting, validation, and guardrail calculations. + */ + +/** + * Formats data for display. If input is a string, attempts to parse as JSON. + * @param input - Data to format (string or object) + * @returns Parsed JSON object or original input + */ +export function formatData(input: any) { + if (typeof input === "string") { + try { + return JSON.parse(input); + } catch { + return input; + } + } + return input; +} + +/** + * Checks if messages array/object contains data. + * @param messages - Messages to check + * @returns True if messages exist and have content + */ +export function checkHasMessages(messages: any): boolean { + if (!messages) return false; + if (Array.isArray(messages)) return messages.length > 0; + if (typeof messages === "object") return Object.keys(messages).length > 0; + return false; +} + +/** + * Checks if response object contains data. + * @param response - Response to check + * @returns True if response exists and has content + */ +export function checkHasResponse(response: any): boolean { + if (!response) return false; + return Object.keys(formatData(response)).length > 0; +} + +/** + * Normalizes guardrail information into an array. + * @param guardrailInfo - Guardrail data (may be array, object, or null) + * @returns Array of guardrail entries + */ +export function normalizeGuardrailEntries(guardrailInfo: any): any[] { + if (Array.isArray(guardrailInfo)) return guardrailInfo; + if (guardrailInfo) return [guardrailInfo]; + return []; +} + +/** + * Calculates total number of masked entities across all guardrail entries. + * @param entries - Array of guardrail entries + * @returns Total count of masked entities + */ +export function calculateTotalMaskedEntities(entries: any[]): number { + return entries.reduce((sum, entry) => { + const maskedCounts = entry?.masked_entity_count; + if (!maskedCounts) return sum; + return ( + sum + + Object.values(maskedCounts).reduce((acc, count) => (typeof count === "number" ? acc + count : acc), 0) + ); + }, 0); +} + +/** + * Gets a display label for guardrail(s). + * @param entries - Array of guardrail entries + * @returns Display string for guardrail label + */ +export function getGuardrailLabel(entries: any[]): string { + if (entries.length === 0) return "-"; + if (entries.length === 1) return entries[0]?.guardrail_name ?? "-"; + return `${entries.length} guardrails`; +} + +/** + * Checks if vector store data exists in metadata. + * @param metadata - Metadata object to check + * @returns True if vector store data exists and is non-empty + */ +export function checkHasVectorStoreData(metadata: Record): boolean { + return ( + metadata.vector_store_request_metadata && + Array.isArray(metadata.vector_store_request_metadata) && + metadata.vector_store_request_metadata.length > 0 + ); +} From e2f7b10c8d0694172fcdc5f072e36c7e5b5d733c Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 30 Jan 2026 18:20:50 -0800 Subject: [PATCH 5/6] address feedback --- .../view_logs/CostBreakdownViewer.tsx | 14 +- .../LogDetailsDrawer/DrawerHeader.tsx | 205 ++++++++ .../view_logs/LogDetailsDrawer/JsonViewer.tsx | 35 ++ .../LogDetailsDrawer/LogDetailsDrawer.tsx | 441 ++++++++++++++++++ .../view_logs/LogDetailsDrawer/TokenFlow.tsx | 22 + .../LogDetailsDrawer/TruncatedValue.tsx | 35 ++ .../LogDetailsDrawer/clipboardUtils.ts | 43 -- .../view_logs/LogDetailsDrawer/constants.ts | 4 +- .../view_logs/LogDetailsDrawer/index.ts | 2 + .../LogDetailsDrawer/useKeyboardNavigation.ts | 87 ++++ .../src/components/view_logs/columns.tsx | 40 -- .../src/components/view_logs/index.tsx | 46 +- .../src/components/view_logs/table.tsx | 24 +- 13 files changed, 885 insertions(+), 113 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/clipboardUtils.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/index.ts create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx index affe28e0b25..c56e2d3af50 100644 --- a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.tsx @@ -7,6 +7,7 @@ export interface CostBreakdown { output_cost?: number; total_cost?: number; tool_usage_cost?: number; + additional_costs?: Record; original_cost?: number; discount_percent?: number; discount_amount?: number; @@ -59,7 +60,7 @@ export const CostBreakdownViewer: React.FC = ({ } return ( -
+
@@ -88,6 +89,17 @@ export const CostBreakdownViewer: React.FC = ({ {formatCost(costBreakdown.tool_usage_cost)}
)} + {/* Additional Costs (free-form) */} + {costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && ( + <> + {Object.entries(costBreakdown.additional_costs).map(([key, value]) => ( +
+ {key}: + {formatCost(value)} +
+ ))} + + )}
{/* Subtotal / Original Cost */} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx new file mode 100644 index 00000000000..25fcdf953e9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -0,0 +1,205 @@ +import { Button, Space, Tag, Tooltip, Typography } from "antd"; +import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; +import moment from "moment"; +import { LogEntry } from "../columns"; +import { getProviderLogoAndName } from "../../provider_info_helpers"; +import { + DRAWER_HEADER_PADDING, + COLOR_BORDER, + COLOR_BACKGROUND, + SPACING_MEDIUM, + SPACING_LARGE, + FONT_SIZE_HEADER, + FONT_SIZE_MEDIUM, + FONT_FAMILY_MONO, + SPACING_SMALL, +} from "./constants"; + +const { Text } = Typography; + +interface DrawerHeaderProps { + log: LogEntry; + onClose: () => void; + onPrevious: () => void; + onNext: () => void; + statusLabel: string; + statusColor: "error" | "success"; + environment: string; +} + +/** + * Header component for the log details drawer. + * Displays model/provider, request ID, navigation controls, status, environment, and timestamp. + */ +export function DrawerHeader({ + log, + onClose, + onPrevious, + onNext, + statusLabel, + statusColor, + environment, +}: DrawerHeaderProps) { + const provider = log.custom_llm_provider || ""; + const providerInfo = provider ? getProviderLogoAndName(provider) : null; + + return ( +
+ {/* Row 0: Model + Provider with Logo */} + + + {/* Row 1: Request ID + Actions */} +
+ + +
+ + {/* Row 2: Status + Env + Timestamp */} + +
+ ); +} + +/** + * Model and Provider display with logo + */ +function ModelProviderSection({ + model, + providerLogo, + providerName, +}: { + model: string; + providerLogo?: string; + providerName?: string; +}) { + return ( + + {providerLogo && ( + {providerName { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} + /> + )} + + + {model} + + {providerName && ( + + {providerName} + + )} + + + ); +} + +/** + * Request ID display with copy functionality + */ +function RequestIdSection({ requestId }: { requestId: string }) { + return ( +
+ + + {requestId} + + +
+ ); +} + +/** + * Navigation controls (previous, next, close) + * Shows keyboard shortcuts with bounding boxes for visibility + */ +function NavigationSection({ + onPrevious, + onNext, + onClose, +}: { + onPrevious: () => void; + onNext: () => void; + onClose: () => void; +}) { + const keyboardShortcutStyle = { + border: "1px solid #d9d9d9", + borderRadius: 4, + padding: "0 4px", + fontSize: 12, + fontFamily: "monospace", + marginLeft: 4, + background: "#fafafa", + }; + + return ( + }> + + + + - ) : ( - - ); - }; - - // Return the component - return ; - }, - }, { header: "Time", accessorKey: "startTime", diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 826fc7ccc02..3859a5e51fb 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -31,6 +31,7 @@ import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsMo import { DataTable } from "./table"; import { VectorStoreViewer } from "./VectorStoreViewer"; import NewBadge from "../common_components/NewBadge"; +import { LogDetailsDrawer } from "./LogDetailsDrawer"; interface SpendLogsTableProps { accessToken: string | null; @@ -89,7 +90,8 @@ export default function SpendLogsTable({ const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole)); const [activeTab, setActiveTab] = useState("request logs"); - const [expandedRequestId, setExpandedRequestId] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [selectedSessionId, setSelectedSessionId] = useState(null); const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false); @@ -317,17 +319,6 @@ export default function SpendLogsTable({ enabled: !!accessToken && !!selectedSessionId, }); - // Add this effect to preserve expanded state when data refreshes - useEffect(() => { - if (logs.data?.data && expandedRequestId) { - // Check if the expanded request ID still exists in the new data - const stillExists = logs.data.data.some((log) => log.request_id === expandedRequestId); - if (!stillExists) { - // If the request ID no longer exists in the data, clear the expanded state - setExpandedRequestId(null); - } - } - }, [logs.data?.data, expandedRequestId]); if (!accessToken || !token || !userRole || !userID) { return null; @@ -367,8 +358,18 @@ export default function SpendLogsTable({ logs.refetch(); }; - const handleRowExpand = (requestId: string | null) => { - setExpandedRequestId(requestId); + const handleRowClick = (log: LogEntry) => { + setSelectedLog(log); + setIsDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setIsDrawerOpen(false); + // Optionally keep selectedLog for animation purposes + }; + + const handleSelectLog = (log: LogEntry) => { + setSelectedLog(log); }; // Function to extract unique error codes from logs @@ -554,9 +555,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} - getRowCanExpand={() => true} - // Optionally: add session-specific row expansion state + onRowClick={handleRowClick} />
) : ( @@ -753,8 +752,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} - getRowCanExpand={() => true} + onRowClick={handleRowClick} />
@@ -775,6 +773,16 @@ export default function SpendLogsTable({ + + {/* Log Details Drawer */} + setIsSpendLogsSettingsModalVisible(true)} + allLogs={filteredData} + onSelectLog={handleSelectLog} + /> ); } diff --git a/ui/litellm-dashboard/src/components/view_logs/table.tsx b/ui/litellm-dashboard/src/components/view_logs/table.tsx index 605341cb2ed..fb7706cba19 100644 --- a/ui/litellm-dashboard/src/components/view_logs/table.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/table.tsx @@ -6,8 +6,10 @@ import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } fro interface DataTableProps { data: TData[]; columns: ColumnDef[]; - renderSubComponent: (props: { row: Row }) => React.ReactElement; - getRowCanExpand: (row: Row) => boolean; + onRowClick?: (row: TData) => void; + // Legacy props for backward compatibility (audit logs) + renderSubComponent?: (props: { row: Row }) => React.ReactElement; + getRowCanExpand?: (row: Row) => boolean; isLoading?: boolean; loadingMessage?: string; noDataMessage?: string; @@ -16,22 +18,26 @@ interface DataTableProps { export function DataTable({ data = [], columns, - getRowCanExpand, + onRowClick, renderSubComponent, + getRowCanExpand, isLoading = false, loadingMessage = "🚅 Loading logs...", noDataMessage = "No logs found", }: DataTableProps) { + // Determine if we're in legacy expansion mode or new drawer mode + const isLegacyMode = !!renderSubComponent && !!getRowCanExpand; + const table = useReactTable({ data, columns, - getRowCanExpand, + ...(isLegacyMode && { getRowCanExpand }), getRowId: (row: TData, index: number) => { const _row: any = row as any; return _row?.request_id ?? String(index); }, getCoreRowModel: getCoreRowModel(), - getExpandedRowModel: getExpandedRowModel(), + ...(isLegacyMode && { getExpandedRowModel: getExpandedRowModel() }), }); return ( @@ -62,7 +68,10 @@ export function DataTable({ ) : table.getRowModel().rows.length > 0 ? ( table.getRowModel().rows.map((row) => ( - + !isLegacyMode && onRowClick?.(row.original)} + > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -70,7 +79,8 @@ export function DataTable({ ))} - {row.getIsExpanded() && ( + {/* Legacy expansion mode for audit logs */} + {isLegacyMode && row.getIsExpanded() && renderSubComponent && (
{renderSubComponent({ row })}
From 5345a763c2a42a4a45a8cbf1c72c75b7baed6234 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 30 Jan 2026 18:34:13 -0800 Subject: [PATCH 6/6] [Feat] v2 - Logs view with side panel and improved UX (#20091) * init: azure_ai/azure-model-router * show additional_costs in CostBreakdown * UI show cost breakdown fields * feat: dedicated cost calc for azure ai * test_azure_ai_model_router * docs azure model router * test azure model router * fix transfrom * Add transform file * fix:feat: route to config * v0 - looks decen view * refactored code * fix ui * fixes ui * complete v2 viewer * address feedback * address feedback --- .../providers/azure_ai/azure_model_router.md | 163 ++++++--- litellm/cost_calculator.py | 65 +++- litellm/litellm_core_utils/litellm_logging.py | 6 + .../azure_ai/azure_model_router/__init__.py | 4 + .../azure_model_router/transformation.py | 125 +++++++ litellm/llms/azure_ai/common_utils.py | 78 +++- litellm/llms/azure_ai/cost_calculator.py | 102 ++++++ litellm/llms/base_llm/chat/transformation.py | 20 ++ ...odel_prices_and_context_window_backup.json | 8 + litellm/types/utils.py | 1 + litellm/utils.py | 5 +- model_prices_and_context_window.json | 8 + tests/llm_translation/test_azure_ai.py | 28 +- .../llms/azure_ai/test_cost_calculator.py | 335 ++++++++++++++++++ 14 files changed, 882 insertions(+), 66 deletions(-) create mode 100644 litellm/llms/azure_ai/azure_model_router/__init__.py create mode 100644 litellm/llms/azure_ai/azure_model_router/transformation.py create mode 100644 litellm/llms/azure_ai/cost_calculator.py create mode 100644 tests/test_litellm/llms/azure_ai/test_cost_calculator.py diff --git a/docs/my-website/docs/providers/azure_ai/azure_model_router.md b/docs/my-website/docs/providers/azure_ai/azure_model_router.md index 5e14c7283f6..16bc1afb70e 100644 --- a/docs/my-website/docs/providers/azure_ai/azure_model_router.md +++ b/docs/my-website/docs/providers/azure_ai/azure_model_router.md @@ -5,19 +5,38 @@ Azure Model Router is a feature in Azure AI Foundry that automatically routes yo ## Key Features - **Automatic Model Selection**: Azure Model Router dynamically selects the best model for your request -- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), not the router endpoint +- **Cost Tracking**: LiteLLM automatically tracks costs based on the actual model used (e.g., `gpt-4.1-nano`), plus the Model Router infrastructure fee - **Streaming Support**: Full support for streaming responses with accurate cost calculation +- **Simple Configuration**: Easy to set up via UI or config file + +## Model Naming Pattern + +Use the pattern: `azure_ai/model_router/` + +**Components:** +- `azure_ai` - The provider identifier +- `model_router` - Indicates this is a Model Router deployment +- `` - Your actual deployment name from Azure AI Foundry (e.g., `azure-model-router`) + +**Example:** `azure_ai/model_router/azure-model-router` + +**How it works:** +- LiteLLM automatically strips the `model_router/` prefix when sending requests to Azure +- Only your deployment name (e.g., `azure-model-router`) is sent to the Azure API +- The full path is preserved in responses and logs for proper cost tracking ## LiteLLM Python SDK ### Basic Usage +Use the pattern `azure_ai/model_router/` where `` is your Azure deployment name: + ```python import litellm import os response = litellm.completion( - model="azure_ai/azure-model-router", + model="azure_ai/model_router/azure-model-router", # Use your deployment name messages=[{"role": "user", "content": "Hello!"}], api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), @@ -26,6 +45,13 @@ response = litellm.completion( print(response) ``` +**Pattern Explanation:** +- `azure_ai` - The provider +- `model_router` - Indicates this is a model router deployment +- `azure-model-router` - Your actual deployment name from Azure AI Foundry + +LiteLLM will automatically strip the `model_router/` prefix when sending the request to Azure, so only `azure-model-router` is sent to the API. + ### Streaming with Usage Tracking ```python @@ -33,7 +59,7 @@ import litellm import os response = await litellm.acompletion( - model="azure_ai/azure-model-router", + model="azure_ai/model_router/azure-model-router", # Use your deployment name messages=[{"role": "user", "content": "hi"}], api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), @@ -51,13 +77,15 @@ async for chunk in response: ```yaml model_list: - - model_name: azure-model-router + - model_name: azure-model-router # Public name for your users litellm_params: - model: azure_ai/azure-model-router + model: azure_ai/model_router/azure-model-router # Use your deployment name api_base: https://your-endpoint.cognitiveservices.azure.com/openai/v1/ api_key: os.environ/AZURE_MODEL_ROUTER_API_KEY ``` +**Note:** Replace `azure-model-router` in the model path with your actual deployment name from Azure AI Foundry. + ### Start Proxy ```bash @@ -80,49 +108,42 @@ curl -X POST http://localhost:4000/chat/completions \ This walkthrough shows how to add an Azure Model Router endpoint to LiteLLM using the Admin Dashboard. -### Select Provider +### Quick Start + +1. Navigate to the **Models** page in the LiteLLM UI +2. Select **"Azure AI Foundry (Studio)"** as the provider +3. Enter your deployment name (e.g., `azure-model-router`) +4. LiteLLM will automatically format it as `azure_ai/model_router/azure-model-router` +5. Add your API base URL and API key +6. Test and save + +### Detailed Walkthrough + +#### Step 1: Select Provider Navigate to the Models page and select "Azure AI Foundry (Studio)" as the provider. -#### Navigate to Models Page +##### Navigate to Models Page ![Navigate to Models](./img/azure_model_router_01.jpeg) -#### Click Provider Dropdown +##### Click Provider Dropdown ![Click Provider](./img/azure_model_router_02.jpeg) -#### Choose Azure AI Foundry +##### Choose Azure AI Foundry ![Select Azure AI Foundry](./img/azure_model_router_03.jpeg) -### Configure Model Name +#### Step 2: Enter Deployment Name -Set up the model name by entering `azure_ai/` followed by your model router deployment name from Azure. +**New Simplified Method:** Just enter your deployment name directly in the text field. If your deployment name contains "model-router" or "model_router", LiteLLM will automatically format it as `azure_ai/model_router/`. -#### Click Model Name Field +**Example:** +- Enter: `azure-model-router` +- LiteLLM creates: `azure_ai/model_router/azure-model-router` -![Click Model Field](./img/azure_model_router_04.jpeg) - -#### Select Custom Model Name - -![Select Custom Model](./img/azure_model_router_05.jpeg) - -#### Enter LiteLLM Model Name - -![LiteLLM Model Name](./img/azure_model_router_06.jpeg) - -#### Click Custom Model Name Field - -![Enter Custom Name Field](./img/azure_model_router_07.jpeg) - -#### Type Model Prefix - -Type `azure_ai/` as the prefix. - -![Type azure_ai prefix](./img/azure_model_router_08.jpeg) - -#### Copy Model Name from Azure Portal +##### Copy Deployment Name from Azure Portal Switch to Azure AI Foundry and copy your model router deployment name. @@ -130,73 +151,79 @@ Switch to Azure AI Foundry and copy your model router deployment name. ![Copy Model Name](./img/azure_model_router_10.jpeg) -#### Paste Model Name +##### Enter Deployment Name in LiteLLM -Paste to get `azure_ai/azure-model-router`. +Paste your deployment name (e.g., `azure-model-router`) directly into the text field. -![Paste Model Name](./img/azure_model_router_11.jpeg) +![Enter Deployment Name](./img/azure_model_router_04.jpeg) -### Configure API Base and Key +**What happens behind the scenes:** +- You enter: `azure-model-router` +- LiteLLM automatically detects this is a model router deployment +- The full model path becomes: `azure_ai/model_router/azure-model-router` +- When making API calls, only `azure-model-router` is sent to Azure + +#### Step 3: Configure API Base and Key Copy the endpoint URL and API key from Azure portal. -#### Copy API Base URL from Azure +##### Copy API Base URL from Azure ![Copy API Base](./img/azure_model_router_12.jpeg) -#### Enter API Base in LiteLLM +##### Enter API Base in LiteLLM ![Click API Base Field](./img/azure_model_router_13.jpeg) ![Paste API Base](./img/azure_model_router_14.jpeg) -#### Copy API Key from Azure +##### Copy API Key from Azure ![Copy API Key](./img/azure_model_router_15.jpeg) -#### Enter API Key in LiteLLM +##### Enter API Key in LiteLLM ![Enter API Key](./img/azure_model_router_16.jpeg) -### Test and Add Model +#### Step 4: Test and Add Model Verify your configuration works and save the model. -#### Test Connection +##### Test Connection ![Test Connection](./img/azure_model_router_17.jpeg) -#### Close Test Dialog +##### Close Test Dialog ![Close Dialog](./img/azure_model_router_18.jpeg) -#### Add Model +##### Add Model ![Add Model](./img/azure_model_router_19.jpeg) -### Verify in Playground +#### Step 5: Verify in Playground Test your model and verify cost tracking is working. -#### Open Playground +##### Open Playground ![Go to Playground](./img/azure_model_router_20.jpeg) -#### Select Model +##### Select Model ![Select Model](./img/azure_model_router_21.jpeg) -#### Send Test Message +##### Send Test Message ![Send Message](./img/azure_model_router_22.jpeg) -#### View Logs +##### View Logs ![View Logs](./img/azure_model_router_23.jpeg) -#### Verify Cost Tracking +##### Verify Cost Tracking -Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`). +Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`), plus a flat infrastructure cost of $0.14 per million input tokens for using the Model Router. ![Verify Cost](./img/azure_model_router_24.jpeg) @@ -205,28 +232,50 @@ Cost is tracked based on the actual model used (e.g., `gpt-4.1-nano`). LiteLLM automatically handles cost tracking for Azure Model Router by: 1. **Detecting the actual model**: When Azure Model Router routes your request to a specific model (e.g., `gpt-4.1-nano-2025-04-14`), LiteLLM extracts this from the response -2. **Calculating accurate costs**: Costs are calculated based on the actual model used, not the router endpoint name +2. **Calculating accurate costs**: Costs are calculated based on: + - The actual model used (e.g., `gpt-4.1-nano` token costs) + - Plus a flat infrastructure cost of **$0.14 per million input tokens** for using the Model Router 3. **Streaming support**: Cost tracking works correctly for both streaming and non-streaming requests +### Cost Breakdown + +When you use Azure Model Router, the total cost includes: + +- **Model Cost**: Based on the actual model that handled your request (e.g., `gpt-4.1-nano`) +- **Router Flat Cost**: $0.14 per million input tokens (Azure AI Foundry infrastructure fee) + ### Example Response with Cost ```python import litellm response = litellm.completion( - model="azure_ai/azure-model-router", + model="azure_ai/model_router/azure-model-router", messages=[{"role": "user", "content": "Hello!"}], api_base="https://your-endpoint.cognitiveservices.azure.com/openai/v1/", api_key="your-api-key", ) # The response will show the actual model used -print(f"Model used: {response.model}") # e.g., "gpt-4.1-nano-2025-04-14" +print(f"Model used: {response.model}") # e.g., "azure_ai/gpt-4.1-nano-2025-04-14" -# Get cost +# Get cost (includes both model cost and router flat cost) from litellm import completion_cost cost = completion_cost(completion_response=response) -print(f"Cost: ${cost}") +print(f"Total cost: ${cost}") + +# Access detailed cost breakdown +if hasattr(response, '_hidden_params') and 'response_cost' in response._hidden_params: + print(f"Response cost: ${response._hidden_params['response_cost']}") ``` +### Viewing Cost Breakdown in UI + +When viewing logs in the LiteLLM UI, you'll see: +- **Model Cost**: The cost for the actual model used +- **Azure Model Router Flat Cost**: The $0.14/M input tokens infrastructure fee +- **Total Cost**: Sum of both costs + +This breakdown helps you understand exactly what you're paying for when using the Model Router. + diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 490f0288b00..bef4d52ce49 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -36,6 +36,9 @@ from litellm.llms.anthropic.cost_calculation import ( from litellm.llms.azure.cost_calculation import ( cost_per_token as azure_openai_cost_per_token, ) +from litellm.llms.azure_ai.cost_calculator import ( + cost_per_token as azure_ai_cost_per_token, +) from litellm.llms.base_llm.search.transformation import SearchResponse from litellm.llms.bedrock.cost_calculation import ( cost_per_token as bedrock_cost_per_token, @@ -138,6 +141,51 @@ def _cost_per_token_custom_pricing_helper( return None +def _get_additional_costs( + model: str, + custom_llm_provider: Optional[str], + prompt_tokens: int, + completion_tokens: int, +) -> Optional[dict]: + """ + Calculate additional costs beyond standard token costs. + + This function delegates to provider-specific config classes to calculate + any additional costs like routing fees, infrastructure costs, etc. + + Args: + model: The model name + custom_llm_provider: The provider name (optional) + prompt_tokens: Number of prompt tokens + completion_tokens: Number of completion tokens + + Returns: + Optional dictionary with cost names and amounts, or None if no additional costs + """ + if not custom_llm_provider: + return None + + try: + config_class = None + if custom_llm_provider == "azure_ai": + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + config_class = AzureFoundryModelInfo.get_azure_ai_config_for_model(model) + # Add more providers here as needed + # elif custom_llm_provider == "other_provider": + # config_class = get_other_provider_config(model) + + if config_class and hasattr(config_class, 'calculate_additional_costs'): + return config_class.calculate_additional_costs( + model=model, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + ) + except Exception as e: + verbose_logger.debug(f"Error calculating additional costs: {e}") + + return None + + def _transcription_usage_has_token_details( usage_block: Optional[Usage], ) -> bool: @@ -427,8 +475,8 @@ def cost_per_token( # noqa: PLR0915 return dashscope_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "azure_ai": - return generic_cost_per_token( - model=model, usage=usage_block, custom_llm_provider=custom_llm_provider + return azure_ai_cost_per_token( + model=model, usage=usage_block, response_time_ms=response_time_ms ) else: model_info = _cached_get_model_info_helper( @@ -805,6 +853,7 @@ def _store_cost_breakdown_in_logging_obj( completion_tokens_cost_usd_dollar: float, cost_for_built_in_tools_cost_usd_dollar: float, total_cost_usd_dollar: float, + additional_costs: Optional[dict] = None, original_cost: Optional[float] = None, discount_percent: Optional[float] = None, discount_amount: Optional[float] = None, @@ -821,6 +870,7 @@ def _store_cost_breakdown_in_logging_obj( completion_tokens_cost_usd_dollar: Cost of completion tokens (includes reasoning if applicable) cost_for_built_in_tools_cost_usd_dollar: Cost of built-in tools total_cost_usd_dollar: Total cost of request + additional_costs: Free-form additional costs dict (e.g., {"azure_model_router_flat_cost": 0.00014}) original_cost: Cost before discount discount_percent: Discount percentage applied (0.05 = 5%) discount_amount: Discount amount in USD @@ -838,6 +888,7 @@ def _store_cost_breakdown_in_logging_obj( output_cost=completion_tokens_cost_usd_dollar, total_cost=total_cost_usd_dollar, cost_for_built_in_tools_cost_usd_dollar=cost_for_built_in_tools_cost_usd_dollar, + additional_costs=additional_costs, original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, @@ -1335,6 +1386,15 @@ def completion_cost( # noqa: PLR0915 service_tier=service_tier, response=completion_response, ) + + # Get additional costs from provider (e.g., routing fees, infrastructure costs) + additional_costs = _get_additional_costs( + model=model, + custom_llm_provider=custom_llm_provider, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + ) + _final_cost = ( prompt_tokens_cost_usd_dollar + completion_tokens_cost_usd_dollar ) @@ -1374,6 +1434,7 @@ def completion_cost( # noqa: PLR0915 completion_tokens_cost_usd_dollar=completion_tokens_cost_usd_dollar, cost_for_built_in_tools_cost_usd_dollar=cost_for_built_in_tools, total_cost_usd_dollar=_final_cost, + additional_costs=additional_costs, original_cost=original_cost, discount_percent=discount_percent, discount_amount=discount_amount, diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 9d1360bf057..4ad2d1002bc 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1297,6 +1297,7 @@ class Logging(LiteLLMLoggingBaseClass): output_cost: float, total_cost: float, cost_for_built_in_tools_cost_usd_dollar: float, + additional_costs: Optional[dict] = None, original_cost: Optional[float] = None, discount_percent: Optional[float] = None, discount_amount: Optional[float] = None, @@ -1312,6 +1313,7 @@ class Logging(LiteLLMLoggingBaseClass): output_cost: Cost of output/completion tokens cost_for_built_in_tools_cost_usd_dollar: Cost of built-in tools total_cost: Total cost of request + additional_costs: Free-form additional costs dict (e.g., {"azure_model_router_flat_cost": 0.00014}) original_cost: Cost before discount discount_percent: Discount percentage (0.05 = 5%) discount_amount: Discount amount in USD @@ -1327,6 +1329,10 @@ class Logging(LiteLLMLoggingBaseClass): tool_usage_cost=cost_for_built_in_tools_cost_usd_dollar, ) + # Store additional costs if provided (free-form dict for extensibility) + if additional_costs and isinstance(additional_costs, dict) and len(additional_costs) > 0: + self.cost_breakdown["additional_costs"] = additional_costs + # Store discount information if provided if original_cost is not None: self.cost_breakdown["original_cost"] = original_cost diff --git a/litellm/llms/azure_ai/azure_model_router/__init__.py b/litellm/llms/azure_ai/azure_model_router/__init__.py new file mode 100644 index 00000000000..0165d60b643 --- /dev/null +++ b/litellm/llms/azure_ai/azure_model_router/__init__.py @@ -0,0 +1,4 @@ +"""Azure AI Foundry Model Router support.""" +from .transformation import AzureModelRouterConfig + +__all__ = ["AzureModelRouterConfig"] diff --git a/litellm/llms/azure_ai/azure_model_router/transformation.py b/litellm/llms/azure_ai/azure_model_router/transformation.py new file mode 100644 index 00000000000..3d6dc53c515 --- /dev/null +++ b/litellm/llms/azure_ai/azure_model_router/transformation.py @@ -0,0 +1,125 @@ +""" +Transformation for Azure AI Foundry Model Router. + +The Model Router is a special Azure AI deployment that automatically routes requests +to the best available model. It has specific cost tracking requirements. +""" +from typing import Any, List, Optional + +from httpx import Response + +from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig +from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj +from litellm.types.llms.openai import AllMessageValues +from litellm.types.utils import ModelResponse + + +class AzureModelRouterConfig(AzureAIStudioConfig): + """ + Configuration for Azure AI Foundry Model Router. + + Handles: + - Stripping model_router prefix before sending to Azure API + - Preserving full model path in responses for cost tracking + - Calculating flat infrastructure costs for Model Router + """ + + def transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Transform request for Model Router. + + Strips the model_router/ prefix so only the deployment name is sent to Azure. + Example: model_router/azure-model-router -> azure-model-router + """ + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + + # Get base model name (strips routing prefixes like model_router/) + base_model: str = AzureFoundryModelInfo.get_base_model(model) + + return super().transform_request( + base_model, messages, optional_params, litellm_params, headers + ) + + def transform_response( + self, + model: str, + raw_response: Response, + model_response: ModelResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ModelResponse: + """ + Transform response for Model Router. + + Preserves the original model path (including model_router/ prefix) in the response + for proper cost tracking and logging. + """ + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + + # Preserve the original model from litellm_params (includes routing prefixes like model_router/) + # This ensures cost tracking and logging use the full model path + original_model: str = litellm_params.get("model") or model + if not original_model.startswith("azure_ai/"): + # Add provider prefix if not already present + model_response.model = f"azure_ai/{original_model}" + else: + model_response.model = original_model + + # Get base model for the parent call (strips routing prefixes for API compatibility) + base_model: str = AzureFoundryModelInfo.get_base_model(model) + + return super().transform_response( + model=base_model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + + def calculate_additional_costs( + self, model: str, prompt_tokens: int, completion_tokens: int + ) -> Optional[dict]: + """ + Calculate additional costs for Azure Model Router. + + Adds a flat infrastructure cost of $0.14 per M input tokens for using the Model Router. + + Args: + model: The model name (should be a model router model) + prompt_tokens: Number of prompt tokens + completion_tokens: Number of completion tokens + + Returns: + Dictionary with additional costs, or None if not applicable. + """ + from litellm.llms.azure_ai.cost_calculator import ( + calculate_azure_model_router_flat_cost, + ) + + flat_cost = calculate_azure_model_router_flat_cost( + model=model, prompt_tokens=prompt_tokens + ) + + if flat_cost > 0: + return {"Azure Model Router Flat Cost": flat_cost} + + return None diff --git a/litellm/llms/azure_ai/common_utils.py b/litellm/llms/azure_ai/common_utils.py index 01a3f5766c6..748680f7e13 100644 --- a/litellm/llms/azure_ai/common_utils.py +++ b/litellm/llms/azure_ai/common_utils.py @@ -13,14 +13,21 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): self._model = model @staticmethod - def get_azure_ai_route(model: str) -> Literal["agents", "default"]: + def get_azure_ai_route(model: str) -> Literal["agents", "model_router", "default"]: """ Get the Azure AI route for the given model. Similar to BedrockModelInfo.get_bedrock_route(). + + Supported routes: + - agents: azure_ai/agents/ + - model_router: azure_ai/model_router/ + - default: standard models """ if "agents/" in model: return "agents" + if "model_router/" in model or "model-router/" in model: + return "model_router" return "default" @staticmethod @@ -75,8 +82,73 @@ class AzureFoundryModelInfo(BaseLLMModelInfo): ######################################################### @staticmethod - def get_base_model(model: str) -> Optional[str]: - raise NotImplementedError("Azure Foundry does not support base model") + def strip_model_router_prefix(model: str) -> str: + """ + Strip the model_router prefix from model name. + + Examples: + - "model_router/gpt-4o" -> "gpt-4o" + - "model-router/gpt-4o" -> "gpt-4o" + - "gpt-4o" -> "gpt-4o" + + Args: + model: Model name potentially with model_router prefix + + Returns: + Model name without the prefix + """ + if "model_router/" in model: + return model.split("model_router/", 1)[1] + if "model-router/" in model: + return model.split("model-router/", 1)[1] + return model + + @staticmethod + def get_base_model(model: str) -> str: + """ + Get the base model name, stripping any Azure AI routing prefixes. + + Args: + model: Model name potentially with routing prefixes + + Returns: + Base model name + """ + # Strip model_router prefix if present + model = AzureFoundryModelInfo.strip_model_router_prefix(model) + return model + + @staticmethod + def get_azure_ai_config_for_model(model: str): + """ + Get the appropriate Azure AI config class for the given model. + + Routes to specialized configs based on model type: + - Model Router: AzureModelRouterConfig + - Claude models: AzureAnthropicConfig + - Default: AzureAIStudioConfig + + Args: + model: The model name + + Returns: + The appropriate config instance + """ + azure_ai_route = AzureFoundryModelInfo.get_azure_ai_route(model) + + if azure_ai_route == "model_router": + from litellm.llms.azure_ai.azure_model_router.transformation import ( + AzureModelRouterConfig, + ) + return AzureModelRouterConfig() + elif "claude" in model.lower(): + from litellm.llms.azure_ai.anthropic.transformation import ( + AzureAnthropicConfig, + ) + return AzureAnthropicConfig() + else: + from litellm.llms.azure_ai.chat.transformation import AzureAIStudioConfig + return AzureAIStudioConfig() def validate_environment( self, diff --git a/litellm/llms/azure_ai/cost_calculator.py b/litellm/llms/azure_ai/cost_calculator.py new file mode 100644 index 00000000000..b6258425a1f --- /dev/null +++ b/litellm/llms/azure_ai/cost_calculator.py @@ -0,0 +1,102 @@ +""" +Azure AI cost calculation helper. +Handles Azure AI Foundry Model Router flat cost and other Azure AI specific pricing. +""" + +from typing import Optional, Tuple + +from litellm._logging import verbose_logger +from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token +from litellm.types.utils import Usage +from litellm.utils import get_model_info + + +def _is_azure_model_router(model: str) -> bool: + """ + Check if the model is Azure AI Foundry Model Router. + + Detects patterns like: + - "azure-model-router" + - "model-router" + - "model_router/" + - "model-router/" + + Args: + model: The model name + + Returns: + bool: True if this is a model router model + """ + model_lower = model.lower() + return ( + "model-router" in model_lower + or "model_router" in model_lower + or model_lower == "azure-model-router" + ) + + +def calculate_azure_model_router_flat_cost(model: str, prompt_tokens: int) -> float: + """ + Calculate the flat cost for Azure AI Foundry Model Router. + + Args: + model: The model name (should be a model router model) + prompt_tokens: Number of prompt tokens + + Returns: + float: The flat cost in USD, or 0.0 if not applicable + """ + if not _is_azure_model_router(model): + return 0.0 + + # Get the model router pricing from model_prices_and_context_window.json + # Use "model_router" as the key (without actual model name suffix) + model_info = get_model_info(model="model_router", custom_llm_provider="azure_ai") + router_flat_cost_per_token = model_info.get("input_cost_per_token", 0) + + if router_flat_cost_per_token > 0: + return prompt_tokens * router_flat_cost_per_token + + return 0.0 + + +def cost_per_token( + model: str, usage: Usage, response_time_ms: Optional[float] = 0.0 +) -> Tuple[float, float]: + """ + Calculate the cost per token for Azure AI models. + + For Azure AI Foundry Model Router: + - Adds a flat cost of $0.14 per million input tokens (from model_prices_and_context_window.json) + - Plus the cost of the actual model used (handled by generic_cost_per_token) + + Args: + model: str, the model name without provider prefix + usage: LiteLLM Usage block + response_time_ms: Optional response time in milliseconds + + Returns: + Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd + """ + # Calculate base cost using generic cost calculator + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider="azure_ai", + ) + + # Add flat cost for Azure Model Router + # The flat cost is defined in model_prices_and_context_window.json for azure_ai/azure-model-router + if _is_azure_model_router(model): + router_flat_cost = calculate_azure_model_router_flat_cost(model, usage.prompt_tokens) + + if router_flat_cost > 0: + verbose_logger.debug( + f"Azure AI Model Router flat cost: ${router_flat_cost:.6f} " + f"({usage.prompt_tokens} tokens × ${router_flat_cost / usage.prompt_tokens:.9f}/token)" + ) + + # Add flat cost to prompt cost + prompt_cost += router_flat_cost + + return prompt_cost, completion_cost diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index 41a1797cebe..ac209904e6e 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -437,3 +437,23 @@ class BaseConfig(ABC): By default, this is true for almost all providers. """ return True + + def calculate_additional_costs( + self, model: str, prompt_tokens: int, completion_tokens: int + ) -> Optional[dict]: + """ + Calculate any additional costs beyond standard token costs. + + This is used for provider-specific infrastructure costs, routing fees, etc. + + Args: + model: The model name + prompt_tokens: Number of prompt tokens + completion_tokens: Number of completion tokens + + Returns: + Optional dictionary with cost names and amounts, e.g.: + {"Infrastructure Fee": 0.001, "Routing Cost": 0.0005} + Returns None if no additional costs apply. + """ + return None diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d874e6ba578..0f84bba941d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1517,6 +1517,14 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "azure_ai/model_router": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 0, + "litellm_provider": "azure_ai", + "mode": "chat", + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-services/", + "comment": "Flat cost of $0.14 per M input tokens for Azure AI Foundry Model Router infrastructure. Use pattern: azure_ai/model_router/ where deployment-name is your Azure deployment (e.g., azure-model-router)" + }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index f063418f92e..6c330d0f83c 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2637,6 +2637,7 @@ class CostBreakdown(TypedDict, total=False): ) total_cost: float # Total cost (input + output + tool usage) tool_usage_cost: float # Cost of usage of built-in tools + additional_costs: Dict[str, float] # Free-form additional costs (e.g., {"azure_model_router_flat_cost": 0.00014}) original_cost: float # Cost before discount (optional) discount_percent: float # Discount percentage applied (e.g., 0.05 = 5%) (optional) discount_amount: float # Discount amount in USD (optional) diff --git a/litellm/utils.py b/litellm/utils.py index 15fec357bf2..7c4eec7ba32 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7914,9 +7914,8 @@ class ProviderConfigManager: @staticmethod def _get_azure_ai_config(model: str) -> BaseConfig: """Get Azure AI config based on model type.""" - if "claude" in model.lower(): - return litellm.AzureAnthropicConfig() - return litellm.AzureAIStudioConfig() + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + return AzureFoundryModelInfo.get_azure_ai_config_for_model(model) @staticmethod def _get_vertex_ai_config(model: str) -> BaseConfig: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d874e6ba578..0f84bba941d 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1517,6 +1517,14 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "azure_ai/model_router": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 0, + "litellm_provider": "azure_ai", + "mode": "chat", + "source": "https://azure.microsoft.com/en-us/pricing/details/ai-services/", + "comment": "Flat cost of $0.14 per M input tokens for Azure AI Foundry Model Router infrastructure. Use pattern: azure_ai/model_router/ where deployment-name is your Azure deployment (e.g., azure-model-router)" + }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, diff --git a/tests/llm_translation/test_azure_ai.py b/tests/llm_translation/test_azure_ai.py index 1633fb4cc82..972ba34a179 100644 --- a/tests/llm_translation/test_azure_ai.py +++ b/tests/llm_translation/test_azure_ai.py @@ -373,10 +373,17 @@ def test_completion_azure_ai_gpt_4o_with_flexible_api_base(api_base): async def test_azure_ai_model_router(): """ Test Azure AI model router non-streaming response cost tracking. + Verifies that the flat cost of $0.14 per M input tokens is applied. + + Tests the pattern: azure_ai/model_router/ + Where deployment-name is the Azure deployment (e.g., "azure-model-router"). + The model_router prefix is stripped before sending to Azure API. """ + from litellm.llms.azure_ai.cost_calculator import calculate_azure_model_router_flat_cost + litellm._turn_on_debug() response = await litellm.acompletion( - model="azure_ai/azure-model-router", + model="azure_ai/model_router/azure-model-router", messages=[{"role": "user", "content": "hi who is this"}], api_base="https://ishaa-mh6uutut-swedencentral.cognitiveservices.azure.com/openai/v1/", api_key=os.getenv("AZURE_MODEL_ROUTER_API_KEY"), @@ -387,6 +394,25 @@ async def test_azure_ai_model_router(): tracked_cost = response._hidden_params["response_cost"] assert tracked_cost > 0 print("Tracked cost: ", tracked_cost) + + # Verify flat cost is included using the helper function + usage = response.usage + if usage and usage.prompt_tokens: + expected_flat_cost = calculate_azure_model_router_flat_cost( + model="model_router/azure-model-router", + prompt_tokens=usage.prompt_tokens + ) + print(f"Prompt tokens: {usage.prompt_tokens}") + print(f"Expected flat cost: ${expected_flat_cost:.9f}") + print(f"Total tracked cost: ${tracked_cost:.9f}") + + # Total cost should be at least the flat cost + assert tracked_cost >= expected_flat_cost, ( + f"Cost ${tracked_cost:.9f} should be >= flat cost ${expected_flat_cost:.9f}" + ) + + # Verify the flat cost is non-zero + assert expected_flat_cost > 0, "Flat cost should be greater than 0" @pytest.mark.asyncio diff --git a/tests/test_litellm/llms/azure_ai/test_cost_calculator.py b/tests/test_litellm/llms/azure_ai/test_cost_calculator.py new file mode 100644 index 00000000000..aab8f8bf926 --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/test_cost_calculator.py @@ -0,0 +1,335 @@ +""" +Test Azure AI cost calculator, especially Model Router flat cost. +""" + +import pytest + +from litellm.llms.azure_ai.cost_calculator import ( + _is_azure_model_router, + cost_per_token, +) +from litellm.types.utils import Usage +from litellm.utils import get_model_info + +# Get the flat cost from model_prices_and_context_window.json +_model_info = get_model_info(model="model_router", custom_llm_provider="azure_ai") +AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS = _model_info.get("input_cost_per_token", 0) * 1_000_000 + + +class TestAzureModelRouterDetection: + """Test that we correctly identify Azure Model Router models. + + Model Router deployments follow the pattern: model_router/ + where deployment-name is the Azure deployment (e.g., 'azure-model-router', 'prod-router') + """ + + @pytest.mark.parametrize( + "model,expected", + [ + # Deployment names containing 'model-router' or 'model_router' + ("azure-model-router", True), + ("AZURE-MODEL-ROUTER", True), + ("model-router", True), + ("MODEL-ROUTER", True), + ("my-model-router-deployment", True), + ("prod-model_router", True), + # New pattern: model_router/ + ("model_router/azure-model-router", True), + ("model-router/prod-router", True), + ("model_router/my-deployment", True), + ("MODEL_ROUTER/AZURE-MODEL-ROUTER", True), + # Non-router models + ("gpt-4o", False), + ("gpt-4o-mini", False), + ("claude-sonnet-4-5", False), + ("my-regular-deployment", False), + ], + ) + def test_is_azure_model_router(self, model: str, expected: bool): + """Test Azure Model Router detection.""" + assert _is_azure_model_router(model) == expected + + +class TestAzureModelRouterPrefix: + """Test Azure Model Router prefix stripping.""" + + @pytest.mark.parametrize( + "model,expected", + [ + # Model router deployments - the deployment name comes after model_router/ + ("model_router/azure-model-router", "azure-model-router"), + ("model-router/my-router-deployment", "my-router-deployment"), + ("model_router/prod-router", "prod-router"), + # Non-router models - should pass through unchanged + ("gpt-4o", "gpt-4o"), + ("azure-model-router", "azure-model-router"), + ("claude-sonnet-4", "claude-sonnet-4"), + ], + ) + def test_strip_model_router_prefix(self, model: str, expected: str): + """Test that model_router prefix is stripped correctly. + + The pattern is: model_router/ + where deployment-name is the Azure deployment (e.g., 'azure-model-router', 'prod-router') + """ + from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo + + result = AzureFoundryModelInfo.strip_model_router_prefix(model) + assert result == expected + + +class TestAzureModelRouterFlatCost: + """Test Azure AI Foundry Model Router flat cost calculation.""" + + def test_model_router_flat_cost_basic(self): + """Test that flat cost is added for Model Router requests.""" + model = "azure-model-router" + usage = Usage( + prompt_tokens=1000, + completion_tokens=500, + total_tokens=1500, + ) + + prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) + + # Calculate expected flat cost + expected_flat_cost = ( + usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 + ) + + # Flat cost should be $0.00014 (1000 tokens × $0.14 / 1M tokens) + assert expected_flat_cost == pytest.approx(0.00014, rel=1e-9) + + # Prompt cost should include the flat cost + # (plus any base cost from the actual model used, which might be 0 if not in model_cost) + assert prompt_cost >= expected_flat_cost + print( + f"Model Router flat cost for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}" + ) + print(f"Total prompt cost: ${prompt_cost:.6f}") + + def test_model_router_flat_cost_large_request(self): + """Test flat cost calculation for larger requests.""" + model = "model-router" + usage = Usage( + prompt_tokens=100_000, + completion_tokens=50_000, + total_tokens=150_000, + ) + + prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) + + # Calculate expected flat cost + expected_flat_cost = ( + usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 + ) + + # Flat cost should be $0.014 (100k tokens × $0.14 / 1M tokens) + assert expected_flat_cost == pytest.approx(0.014, rel=1e-9) + assert prompt_cost >= expected_flat_cost + print( + f"Model Router flat cost for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}" + ) + print(f"Total prompt cost: ${prompt_cost:.6f}") + + def test_model_router_flat_cost_1m_tokens(self): + """Test flat cost for exactly 1 million input tokens.""" + model = "azure-model-router" + usage = Usage( + prompt_tokens=1_000_000, + completion_tokens=100_000, + total_tokens=1_100_000, + ) + + prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) + + # Calculate expected flat cost + expected_flat_cost = AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS + + # Flat cost should be exactly $0.14 for 1M tokens + assert expected_flat_cost == pytest.approx(0.14, rel=1e-9) + assert prompt_cost >= expected_flat_cost + print(f"Model Router flat cost for 1M tokens: ${expected_flat_cost:.6f}") + print(f"Total prompt cost: ${prompt_cost:.6f}") + + def test_non_model_router_no_flat_cost(self): + """Test that non-Model Router models don't get the flat cost.""" + model = "gpt-4o" + usage = Usage( + prompt_tokens=1000, + completion_tokens=500, + total_tokens=1500, + ) + + prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) + + # No flat cost should be added for non-Model Router models + # The cost might be 0 or based on the model's pricing + print(f"Non-Model Router prompt cost: ${prompt_cost:.6f}") + # We just ensure it doesn't crash and returns valid values + assert prompt_cost >= 0 + assert completion_cost >= 0 + + def test_model_router_with_cached_tokens(self): + """Test Model Router flat cost with cached tokens.""" + model = "azure-model-router" + usage = Usage( + prompt_tokens=2000, + completion_tokens=800, + total_tokens=2800, + cache_read_input_tokens=500, + cache_creation_input_tokens=200, + ) + + prompt_cost, completion_cost = cost_per_token(model=model, usage=usage) + + # Flat cost is based on ALL prompt tokens (including cached) + expected_flat_cost = ( + usage.prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 + ) + + assert expected_flat_cost == pytest.approx(0.00028, rel=1e-9) + assert prompt_cost >= expected_flat_cost + print( + f"Model Router flat cost with caching for {usage.prompt_tokens} tokens: ${expected_flat_cost:.6f}" + ) + print(f"Total prompt cost: ${prompt_cost:.6f}") + + +class TestAzureModelRouterCostBreakdown: + """Test that Azure Model Router flat cost is tracked in cost breakdown.""" + + def test_flat_cost_calculation_helper(self): + """Test that flat cost can be calculated using the helper function.""" + from litellm.llms.azure_ai.cost_calculator import ( + calculate_azure_model_router_flat_cost, + ) + + model = "azure-model-router" + prompt_tokens = 10000 + + # Calculate flat cost using helper function + flat_cost = calculate_azure_model_router_flat_cost( + model=model, prompt_tokens=prompt_tokens + ) + + # Expected flat cost + expected_flat_cost = ( + prompt_tokens * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 + ) + + assert flat_cost > 0 + assert flat_cost == pytest.approx(expected_flat_cost, rel=1e-9) + print(f"Flat cost calculated: ${flat_cost:.6f}") + + def test_flat_cost_integration_with_completion_cost(self): + """Test that flat cost is properly integrated into completion_cost calculation.""" + import litellm + from litellm.cost_calculator import completion_cost + from litellm.types.utils import Choices, Message, ModelResponse, Usage + + # Create a mock response for azure_ai model router + response = ModelResponse( + id="test-123", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + role="assistant", + content="Test response", + ), + ) + ], + created=1234567890, + model="azure-model-router", + object="chat.completion", + usage=Usage( + prompt_tokens=5000, + completion_tokens=2000, + total_tokens=7000, + ), + ) + + # Set hidden params for provider + response._hidden_params = {"custom_llm_provider": "azure_ai"} + + # Calculate cost + cost = completion_cost( + completion_response=response, + model="azure-model-router", + custom_llm_provider="azure_ai", + ) + + # Expected flat cost + expected_flat_cost = ( + 5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 + ) + + # Cost should include the flat cost + assert cost > expected_flat_cost + print(f"Total cost with flat fee: ${cost:.6f}") + print(f"Expected minimum flat cost: ${expected_flat_cost:.6f}") + + def test_additional_costs_in_cost_breakdown(self): + """Test that Azure Model Router flat cost appears in additional_costs dict.""" + from litellm.cost_calculator import completion_cost + from litellm.litellm_core_utils.litellm_logging import LitellmLoggingObject + from litellm.types.utils import Choices, Message, ModelResponse, Usage + + # Create logging object + logging_obj = LitellmLoggingObject() + + # Create a mock response for azure_ai model router + response = ModelResponse( + id="test-123", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + role="assistant", + content="Test response", + ), + ) + ], + created=1234567890, + model="azure-model-router", + object="chat.completion", + usage=Usage( + prompt_tokens=5000, + completion_tokens=2000, + total_tokens=7000, + ), + ) + + # Set hidden params for provider + response._hidden_params = {"custom_llm_provider": "azure_ai"} + + # Calculate cost with logging object + cost = completion_cost( + completion_response=response, + model="azure-model-router", + custom_llm_provider="azure_ai", + litellm_logging_obj=logging_obj, + ) + + # Check that cost breakdown contains additional_costs + assert hasattr(logging_obj, "cost_breakdown") + assert logging_obj.cost_breakdown is not None + assert "additional_costs" in logging_obj.cost_breakdown + assert isinstance(logging_obj.cost_breakdown["additional_costs"], dict) + + # Check that the Azure Model Router flat cost is in additional_costs + additional_costs = logging_obj.cost_breakdown["additional_costs"] + assert "Azure Model Router Flat Cost" in additional_costs + + # Verify the flat cost value + expected_flat_cost = ( + 5000 * AZURE_MODEL_ROUTER_FLAT_COST_PER_M_INPUT_TOKENS / 1_000_000 + ) + actual_flat_cost = additional_costs["Azure Model Router Flat Cost"] + assert actual_flat_cost == pytest.approx(expected_flat_cost, rel=1e-9) + + print(f"Additional costs in breakdown: {additional_costs}") + print(f"Azure Model Router Flat Cost: ${actual_flat_cost:.6f}")