diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index d1c1f9ddd95..e3e5337684d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -108,6 +108,8 @@ const routeFor = (slug: string): string => { return "policies"; case "routing-groups": return "routing-groups"; + case "model-rollout": + return "model-rollout"; // tools case "mcp-servers": diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index fc7c547293b..6745add2016 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -38,6 +38,7 @@ import Usage from "@/components/usage"; import UserDashboard from "@/components/user_dashboard"; import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage"; import RoutingGroupsView from "@/components/routing_groups/RoutingGroupsView"; +import ModelRollout from "@/components/model_rollout/ModelRollout"; import { ProjectsPage } from "@/components/Projects/ProjectsPage"; import VectorStoreManagement from "@/components/vector_store_management"; import ToolPolicies from "@/components/ToolPolicies"; @@ -437,6 +438,8 @@ function CreateKeyPageContent() { userRole={userRole ?? ""} userId={userID ?? ""} /> + ) : page == "model-rollout" ? ( + ) : page == "models" ? ( , roles: rolesWithWriteAccess, + children: [ + { + key: "routing-groups", + page: "routing-groups", + label: "Routing Groups", + icon: , + }, + { + key: "model-rollout", + page: "model-rollout", + label: ( + + Model Rollout + + ), + icon: , + }, + ], }, { key: "agents", @@ -522,6 +541,17 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse const selectedMenuKey = findMenuItemKey(defaultSelectedKey); + const findParentKey = (page: string): string | null => { + for (const group of menuGroups) { + for (const item of group.items) { + if (item.children?.some((c) => c.page === page)) return item.key; + } + } + return null; + }; + const parentKey = findParentKey(defaultSelectedKey); + const openKeys = parentKey ? [parentKey] : []; + return ( = ({ setPage, defaultSelectedKey, collapse + c.liveModel && + c.candidateModel && + c.liveModel !== c.candidateModel && + (c.liveModel === model || c.candidateModel === model), + ) ?? null; +} + +function saveConfigs(configs: RolloutConfig[]) { + localStorage.setItem(ROLLOUT_STORAGE_KEY, JSON.stringify(configs)); +} + +function newId(): string { + return Math.random().toString(36).slice(2, 10); +} + +interface ModelRolloutProps { + accessToken: string | null; +} + +const CANARY_PRESETS = [5, 10, 25, 50, 100]; + +const ModelRollout: React.FC = ({ accessToken }) => { + const [models, setModels] = useState([]); + const [configs, setConfigs] = useState([]); + + useEffect(() => { + if (!accessToken) return; + fetchAvailableModels(accessToken).then(setModels).catch(console.error); + }, [accessToken]); + + useEffect(() => { + setConfigs(getRolloutConfigs()); + }, []); + + const modelOptions = Array.from(new Set(models.map((m) => m.model_group))).map((name) => ({ + value: name, + label: name, + })); + + const persist = (next: RolloutConfig[]) => { + setConfigs(next); + saveConfigs(next); + }; + + const addNew = () => { + persist([ + ...configs, + { + id: newId(), + liveModel: "", + candidateModel: "", + canaryPercent: 10, + autoRollback: true, + errorThreshold: 5, + }, + ]); + }; + + const update = (id: string, patch: Partial) => { + persist(configs.map((c) => (c.id === id ? { ...c, ...patch } : c))); + }; + + const remove = (id: string) => { + persist(configs.filter((c) => c.id !== id)); + }; + + const statusFor = (c: RolloutConfig) => { + if (!c.liveModel || !c.candidateModel) return { label: "Not Configured", color: "default" as const }; + if (c.canaryPercent === 100) return { label: "Fully Promoted", color: "green" as const }; + if (c.canaryPercent === 0) return { label: "Not Started", color: "default" as const }; + return { label: `${c.canaryPercent}% canary`, color: "blue" as const }; + }; + + return ( +
+
+
+

Model Rollout

+ + Manage A/B tests across your models · {configs.length} active + +
+ +
+ + {configs.length === 0 && ( +
+ No rollout tests configured. Click “New A/B Test” to get started. +
+ )} + +
+ {configs.map((config) => { + const status = statusFor(config); + const livePercent = 100 - config.canaryPercent; + return ( +
+ {/* Header */} +
+
+ + {config.liveModel || "Select live model"} + + + + {config.candidateModel || "Select candidate"} + + {status.label} +
+ remove(config.id)} + okText="Delete" + okButtonProps={{ danger: true }} + > +
+ + {/* Body */} +
+
+
+ + update(config.id, { candidateModel: v })} + options={modelOptions} + style={{ width: "100%" }} + showSearch + /> +
+
+ +
+ update(config.id, { autoRollback: v })} + size="small" + /> + at + update(config.id, { errorThreshold: v ?? 5 })} + size="small" + style={{ width: 60 }} + /> + % errors +
+
+
+ + {/* Traffic split */} +
+ +
+
+ update(config.id, { canaryPercent: v })} + tooltip={{ formatter: (v) => `${v}% canary` }} + /> +
+
+ {CANARY_PRESETS.map((p) => ( + + ))} +
+
+
+
+
+ ); + })} +
+
+ ); +}; + +export default ModelRollout; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx index df04eece289..f905fe889e2 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx @@ -17,6 +17,7 @@ import { SafetyOutlined, SettingOutlined, SoundOutlined, + SwapOutlined, TagsOutlined, ToolOutlined, UserOutlined, @@ -71,6 +72,7 @@ import SessionManagement from "./SessionManagement"; import RealtimePlayground from "./RealtimePlayground"; import { A2ATaskMetadata, MessageType } from "./types"; import { useCodeInterpreter } from "./useCodeInterpreter"; +import { findRolloutForModel } from "../../model_rollout/ModelRollout"; const { TextArea } = Input; const { Dragger } = Upload; @@ -238,6 +240,7 @@ const ChatUI: React.FC = ({ const [maxTokens, setMaxTokens] = useState(2048); const [useAdvancedParams, setUseAdvancedParams] = useState(false); const [mockTestFallbacks, setMockTestFallbacks] = useState(false); + const [lastRolloutInfo, setLastRolloutInfo] = useState<{ requestedModel: string; actualModel: string } | null>(null); // Code Interpreter state (using custom hook) const codeInterpreter = useCodeInterpreter(); @@ -966,6 +969,23 @@ const ChatUI: React.FC = ({ codeInterpreter.clearResult(); // Clear previous code interpreter results setIsLoading(true); + // Model rollout: check if selected model has an A/B test configured + let effectiveModel = selectedModel; + let rolloutApplied = false; + const rolloutConfig = selectedModel ? findRolloutForModel(selectedModel) : null; + if (rolloutConfig) { + const roll = Math.random() * 100; + if (roll < rolloutConfig.canaryPercent) { + effectiveModel = rolloutConfig.candidateModel; + } else { + effectiveModel = rolloutConfig.liveModel; + } + rolloutApplied = effectiveModel !== selectedModel; + setLastRolloutInfo({ requestedModel: selectedModel!, actualModel: effectiveModel! }); + } else { + setLastRolloutInfo(null); + } + try { if (selectedModel) { if (endpointType === EndpointType.CHAT) { @@ -987,8 +1007,8 @@ const ChatUI: React.FC = ({ : (customProxyBaseUrl || undefined); await makeOpenAIChatCompletionRequest( apiChatHistory, - (chunk, model) => updateTextUI("assistant", chunk, model), - selectedModel, + (chunk, model) => updateTextUI("assistant", chunk, rolloutApplied ? `${effectiveModel} (rollout from ${selectedModel})` : model), + effectiveModel || selectedModel, effectiveApiKey, selectedTags, signal, @@ -1069,8 +1089,8 @@ const ChatUI: React.FC = ({ await makeOpenAIResponsesRequest( apiChatHistory, - (role, delta, model) => updateTextUI(role, delta, model), - selectedModel, + (role, delta, model) => updateTextUI(role, delta, rolloutApplied ? `${effectiveModel} (rollout from ${selectedModel})` : model), + effectiveModel || selectedModel, effectiveApiKey, selectedTags, signal, @@ -1101,8 +1121,8 @@ const ChatUI: React.FC = ({ await makeAnthropicMessagesRequest( apiChatHistory, - (role, delta, model) => updateTextUI(role, delta, model), - selectedModel, + (role, delta, model) => updateTextUI(role, delta, rolloutApplied ? `${effectiveModel} (rollout from ${selectedModel})` : model), + effectiveModel || selectedModel, effectiveApiKey, selectedTags, signal, @@ -1520,6 +1540,34 @@ const ChatUI: React.FC = ({ )} + {/* Model Rollout indicator */} + {lastRolloutInfo && ( +
+
+ Model Rollout Active +
+
+ Requested: + {lastRolloutInfo.requestedModel} +
+
+ Sent to: + + {lastRolloutInfo.actualModel} + +
+
+ )} + {/* Agent Selector - shown ONLY for A2A Agents endpoint */} {endpointType === EndpointType.A2A_AGENTS && (
@@ -1891,9 +1939,21 @@ const ChatUI: React.FC = ({
{message.role} {message.role === "assistant" && message.model && ( - - {message.model} - + message.model.includes("(rollout from") ? ( + + + {message.model.split(" (rollout from")[0]} + + via rollout + + {message.model.match(/\(rollout from (.+)\)/)?.[1] ?? ""} + + + ) : ( + + {message.model} + + ) )} {message.reasoningContent && } diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingFlowVisualization.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingFlowVisualization.tsx index b2ac5add32e..4b029776c1a 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingFlowVisualization.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingFlowVisualization.tsx @@ -10,9 +10,17 @@ interface Deployment { priority?: number; } +export interface SimTrace { + steps: { index: number; status: "success" | "fail" | "skipped" }[]; + selectedIndex: number | null; +} + interface RoutingFlowVisualizationProps { strategy: string; deployments: Deployment[]; + failingIndices?: Set; + simTrace?: SimTrace | null; + onToggleFail?: (index: number) => void; } const DEPLOY_COLORS = ["#3b82f6", "#6366f1", "#0ea5e9"]; @@ -72,43 +80,121 @@ const OutputCard: React.FC<{ subtitle: string }> = ({ subtitle }) => ( ); -const ModelNodeCard: React.FC<{ label: string; name: string; subName: string }> = ({ label, name, subName }) => ( -
-
-
{label}
-
-
-
- {name.charAt(0).toUpperCase()} +type NodeStatus = "idle" | "failing" | "sim-fail" | "sim-success" | "sim-skipped"; + +const FailDot: React.FC = () => ( +
+); + +const ModelNodeCard: React.FC<{ + label: string; name: string; subName: string; + status?: NodeStatus; onClick?: () => void; +}> = ({ label, name, subName, status = "idle", onClick }) => { + const isFailing = status === "failing" || status === "sim-fail"; + const isSuccess = status === "sim-success"; + const isSkipped = status === "sim-skipped"; + + let borderColor = "#e5e7eb"; + let bg = "#fff"; + if (isSuccess) { borderColor = "#22c55e"; bg = "#f0fdf4"; } + else if (status === "sim-fail") { borderColor = "#ef4444"; bg = "#fef2f2"; } + else if (isSkipped) { bg = "#f9fafb"; } + + return ( +
+
+ {isFailing ? : } +
+ {onClick && ( +
+ {isFailing ? "click: unfail" : "click: fail"}
-
-
{name}
-
{subName}
+ )} +
{label}
+
+
+
+ {name.charAt(0).toUpperCase()} +
+
+
{name}
+
{subName}
+
+ {isSuccess && ( +
+ + Request handled +
+ )} + {status === "sim-fail" && ( +
+ + Failed — trying next +
+ )}
-
-); + ); +}; -const FailoverFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }) => ( -
- - {deployments.map((dep, i) => { - const label = i === 0 ? "PRIMARY" : `FALLBACK-${i}`; - const shortName = dep.model_name.split("/").pop() || dep.model_name; - return ( - - {i === 0 ? : } - - - ); - })} - - -
-); +const FailoverFlow: React.FC<{ + deployments: Deployment[]; + failingIndices?: Set; + simTrace?: SimTrace | null; + onToggleFail?: (index: number) => void; +}> = ({ deployments, failingIndices, simTrace, onToggleFail }) => { + const getStatus = (i: number): NodeStatus => { + if (simTrace) { + const step = simTrace.steps.find((s) => s.index === i); + if (step) return step.status === "success" ? "sim-success" : "sim-fail"; + return "sim-skipped"; + } + if (failingIndices?.has(i)) return "failing"; + return "idle"; + }; -const WeightedFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }) => { + return ( +
+ + {deployments.map((dep, i) => { + const label = i === 0 ? "PRIMARY" : `FALLBACK-${i}`; + const shortName = dep.model_name.split("/").pop() || dep.model_name; + const nodeStatus = getStatus(i); + return ( + + {i === 0 ? : } + onToggleFail(i) : undefined} + /> + + ); + })} + + +
+ ); +}; + +const WeightedFlow: React.FC<{ + deployments: Deployment[]; + failingIndices?: Set; + simTrace?: SimTrace | null; + onToggleFail?: (index: number) => void; +}> = ({ deployments, failingIndices, simTrace, onToggleFail }) => { const totalWeight = deployments.reduce((s, d) => s + (d.weight ?? 1), 0); return ( @@ -136,24 +222,43 @@ const WeightedFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }) const pct = totalWeight > 0 ? Math.round(((dep.weight ?? 1) / totalWeight) * 100) : 0; const shortName = dep.model_name.split("/").pop() || dep.model_name; const color = DEPLOY_COLORS[i % DEPLOY_COLORS.length]; + const isFailing = failingIndices?.has(i); + const simStep = simTrace?.steps.find((s) => s.index === i); + const isSimSuccess = simStep?.status === "success"; + const isSimFail = simStep?.status === "fail"; + const isSkipped = simTrace && !simStep; + + let rowBg = "#f9fafb"; + let rowBorder = "1px solid #f3f4f6"; + if (isSimSuccess) { rowBg = "#f0fdf4"; rowBorder = "1.5px solid #22c55e"; } + else if (isSimFail) { rowBg = "#fef2f2"; rowBorder = "1.5px solid #fca5a5"; } + return (
onToggleFail(i) : undefined} + style={{ + padding: "8px 12px", backgroundColor: rowBg, borderRadius: 8, border: rowBorder, + cursor: onToggleFail ? "pointer" : "default", opacity: isSkipped ? 0.4 : 1, transition: "all 0.2s", + }} >
{shortName.charAt(0).toUpperCase()}
-
{shortName}
+
+ {shortName} + {isSimSuccess && ✓ selected} + {isSimFail && ✗ failed} +
{dep.model_name}
{pct}% -
+ {isFailing || isSimFail ? :
}
); @@ -230,7 +335,10 @@ const ComplexityFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }
); -const SmartRoutingFlow: React.FC<{ deployments: Deployment[]; strategy: string }> = ({ deployments, strategy }) => { +const SmartRoutingFlow: React.FC<{ + deployments: Deployment[]; strategy: string; + failingIndices?: Set; simTrace?: SimTrace | null; onToggleFail?: (index: number) => void; +}> = ({ deployments, strategy, failingIndices, simTrace, onToggleFail }) => { const meta = STRATEGY_META[strategy] ?? STRATEGY_META["simple-shuffle"]; return (
@@ -243,25 +351,48 @@ const SmartRoutingFlow: React.FC<{ deployments: Deployment[]; strategy: string }
{deployments.map((dep, i) => { const shortName = dep.model_name.split("/").pop() || dep.model_name; + const isFailing = failingIndices?.has(i); + const simStep = simTrace?.steps.find((s) => s.index === i); + const isSimSuccess = simStep?.status === "success"; + const isSkipped = simTrace && !simStep; + + let rowBg = "#f9fafb"; + let rowBorder = "1px solid #f3f4f6"; + if (isSimSuccess) { rowBg = "#f0fdf4"; rowBorder = "1.5px solid #22c55e"; } + return ( -
+
onToggleFail(i) : undefined} + style={{ + padding: "8px 12px", backgroundColor: rowBg, borderRadius: 8, border: rowBorder, + cursor: onToggleFail ? "pointer" : "default", opacity: isSkipped ? 0.4 : 1, transition: "all 0.2s", + }} + >
{shortName.charAt(0).toUpperCase()}
-
{shortName}
+
+ {shortName} + {isSimSuccess && ✓ selected} +
{dep.model_name}
- + {isFailing ? : }
); })}
- +
); }; @@ -271,26 +402,27 @@ const EmptyFlow: React.FC = () => (
- Add deployments to see the flow + Add models to see the flow
); -export default function RoutingFlowVisualization({ strategy, deployments }: RoutingFlowVisualizationProps) { +export default function RoutingFlowVisualization({ strategy, deployments, failingIndices, simTrace, onToggleFail }: RoutingFlowVisualizationProps) { if (strategy === "complexity-router") return ; if (deployments.length === 0) return ; + const simProps = { failingIndices, simTrace, onToggleFail }; switch (strategy) { - case "priority-failover": return ; - case "weighted": return ; + case "priority-failover": return ; + case "weighted": return ; case "simple-shuffle": case "latency-based-routing": case "usage-based-routing-v2": case "cost-based-routing": case "least-busy": - return ; - default: return ; + return ; + default: return ; } } diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupBuilder.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupBuilder.tsx index 94087bd36de..8ca7a8d536c 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupBuilder.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupBuilder.tsx @@ -21,6 +21,7 @@ import { routingGroupCreateCall, routingGroupUpdateCall } from "@/components/net import ModelSelector from "@/components/common_components/ModelSelector"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import RoutingFlowVisualization, { DEPLOY_COLORS } from "./RoutingFlowVisualization"; +import type { SimTrace } from "./RoutingFlowVisualization"; interface Deployment { model_id: string; @@ -112,6 +113,8 @@ export default function RoutingGroupBuilder({ const [logicalName, setLogicalName] = useState(""); const [submitting, setSubmitting] = useState(false); const [tierModels, setTierModels] = useState>({ SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" }); + const [failingIndices, setFailingIndices] = useState>(new Set()); + const [simTrace, setSimTrace] = useState(null); const dragItem = useRef(null); const dragOverItem = useRef(null); @@ -153,6 +156,64 @@ export default function RoutingGroupBuilder({ setDeployments(deps); }; + const toggleFail = (index: number) => { + setSimTrace(null); + setFailingIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); else next.add(index); + return next; + }); + }; + + const runSimulation = () => { + if (deployments.length === 0) return; + const steps: SimTrace["steps"] = []; + let selectedIndex: number | null = null; + + if (strategy === "priority-failover") { + for (let i = 0; i < deployments.length; i++) { + if (failingIndices.has(i)) { + steps.push({ index: i, status: "fail" }); + } else { + steps.push({ index: i, status: "success" }); + selectedIndex = i; + break; + } + } + } else if (strategy === "weighted") { + const available = deployments.map((d, i) => ({ i, w: d.weight ?? 1 })).filter((x) => !failingIndices.has(x.i)); + if (available.length > 0) { + const totalW = available.reduce((s, x) => s + x.w, 0); + let r = Math.random() * totalW; + for (const x of available) { + r -= x.w; + if (r <= 0) { selectedIndex = x.i; break; } + } + if (selectedIndex === null) selectedIndex = available[available.length - 1].i; + } + for (let i = 0; i < deployments.length; i++) { + if (failingIndices.has(i)) steps.push({ index: i, status: "fail" }); + else if (i === selectedIndex) steps.push({ index: i, status: "success" }); + } + } else { + const available = deployments.map((_, i) => i).filter((i) => !failingIndices.has(i)); + if (available.length > 0) { + selectedIndex = available[Math.floor(Math.random() * available.length)]; + } + for (let i = 0; i < deployments.length; i++) { + if (failingIndices.has(i)) steps.push({ index: i, status: "fail" }); + else if (i === selectedIndex) steps.push({ index: i, status: "success" }); + } + } + + setSimTrace({ steps, selectedIndex }); + }; + + const resetSimulation = () => { + setSimTrace(null); + setFailingIndices(new Set()); + }; + const handleStrategyChange = (value: string) => { setStrategy(value); setDeployments((prev) => { @@ -261,6 +322,9 @@ export default function RoutingGroupBuilder({ routing_strategy: strategy, deployments, }; + if (isComplexityRouter) { + payload.settings = { tiers: tierModels }; + } if (isEditing && editTarget?.routing_group_id) { await routingGroupUpdateCall(token, editTarget.routing_group_id as string, payload); message.success("Routing group updated"); @@ -343,7 +407,7 @@ export default function RoutingGroupBuilder({ ) : ( <>
-
2 · Deployments
+
2 · Model Group
- ))} -
-
{/* Flow canvas */} -
+
+ {strategy !== "complexity-router" && deployments.length > 0 && ( +
+ Click any deployment to toggle failure · then press Send Request to simulate +
+ )}
- +
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index 533e51d31bb..8eb2ad67763 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -132,6 +132,9 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
+ {/* Routing Info */} + + {/* Metrics */} @@ -499,6 +502,193 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[ ); } +const STRATEGY_LABELS: Record = { + "simple-shuffle": "Random", + "latency-based-routing": "Lowest Latency", + "usage-based-routing-v2": "Usage-Based", + "cost-based-routing": "Lowest Cost", + "least-busy": "Least Busy", + "priority-failover": "Ordered Fallback", + "weighted-round-robin": "Weighted Round Robin", + "complexity-router": "Complexity Router", +}; + +function RoutingSection({ + metadata, + modelGroup, + selectedModel, + selectedModelId, +}: { + metadata: Record; + modelGroup?: string; + selectedModel: string; + selectedModelId: string; +}) { + const modelGroupSize = metadata?.model_group_size; + const routingStrategy = metadata?.routing_strategy; + const candidates: Array<{ model_id?: string; model_name?: string; litellm_model?: string; priority?: number }> = + metadata?.model_group_candidates || []; + + if (!modelGroup) return null; + + const isRoutingGroup = modelGroup !== selectedModel; + const strategyLabel = (routingStrategy && STRATEGY_LABELS[routingStrategy]) || routingStrategy; + const isOrdered = routingStrategy === "priority-failover"; + const sortedCandidates = isOrdered + ? [...candidates].sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999)) + : candidates; + + return ( +
+ + Routing + {isRoutingGroup && ( + Routing Group + )} +
+ } + size="small" + bordered={false} + style={{ marginBottom: 0 }} + > + + + {modelGroup} + + + {selectedModel} + {selectedModelId && ( + + ({selectedModelId.slice(0, 8)}…) + + )} + + {routingStrategy && ( + + {strategyLabel} + + )} + {modelGroupSize != null && ( + + {modelGroupSize} deployment{modelGroupSize !== 1 ? "s" : ""} + + )} + + + {/* Candidate list */} + {sortedCandidates.length > 0 && ( +
+
+ {isOrdered ? "Fallback Order" : "Candidates Considered"} +
+
+ {sortedCandidates.map((c, idx) => { + const isSelected = c.model_id === selectedModelId; + const label = c.litellm_model || c.model_name || c.model_id || "unknown"; + return ( +
+
+ {isOrdered && ( +
+ {idx + 1} +
+ )} + {!isOrdered && ( +
+ )} +
+ + {label} + + {c.model_id && ( + + {c.model_id.slice(0, 8)}… + + )} +
+ {isSelected && ( + selected + )} + {isOrdered && !isSelected && idx === 0 && ( + primary + )} +
+ {/* Connector line between items */} + {isOrdered && idx < sortedCandidates.length - 1 && ( +
+
+ ↓ fallback +
+ )} +
+ ); + })} +
+
+ )} + + {/* Explanation */} + {modelGroupSize != null && modelGroupSize > 1 && ( +
+
+ {routingStrategy === "simple-shuffle" && ( + <>Randomly selected from {modelGroupSize} available deployments. + )} + {routingStrategy === "latency-based-routing" && ( + <>Picked the lowest-latency deployment from {modelGroupSize} candidates. + )} + {routingStrategy === "usage-based-routing-v2" && ( + <>Picked the deployment with the most available TPM/RPM capacity. + )} + {routingStrategy === "cost-based-routing" && ( + <>Picked the lowest-cost deployment from {modelGroupSize} candidates. + )} + {routingStrategy === "least-busy" && ( + <>Picked the deployment with the fewest active requests. + )} + {routingStrategy === "priority-failover" && ( + <>Tried deployments in priority order. { + sortedCandidates.length > 0 && sortedCandidates[0]?.model_id === selectedModelId + ? "Primary deployment was healthy." + : `Primary was unavailable — fell back to priority ${(sortedCandidates.findIndex(c => c.model_id === selectedModelId) + 1) || "?"}.` + } + )} + {!routingStrategy && ( + <>Router selected {selectedModel} from {modelGroupSize} deployments. + )} + {routingStrategy && !["simple-shuffle", "latency-based-routing", "usage-based-routing-v2", "cost-based-routing", "least-busy", "priority-failover"].includes(routingStrategy) && ( + <>Strategy: {strategyLabel}. Selected from {modelGroupSize} candidates. + )} +
+
+ )} + +
+ ); +} + function MetadataSection({ metadata }: { metadata: Record }) { return (
diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 7cea1a36383..925ab16452f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -41,6 +41,7 @@ export type LogEntry = { team_id: string; model: string; model_id: string; + model_group?: string; api_base?: string; call_type: string; spend: number;