This commit is contained in:
Ishaan Jaffer 2026-03-02 21:49:56 -08:00
parent 4611f8f889
commit 259e10b2fc
9 changed files with 826 additions and 74 deletions

View file

@ -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":

View file

@ -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" ? (
<ModelRollout accessToken={accessToken} />
) : page == "models" ? (
<OldModelDashboard
token={token}

View file

@ -11,6 +11,7 @@ import {
BookOutlined,
BranchesOutlined,
CreditCardOutlined,
DeploymentUnitOutlined,
DatabaseOutlined,
ExperimentOutlined,
FileTextOutlined,
@ -89,11 +90,29 @@ const menuGroups: MenuGroup[] = [
roles: rolesWithWriteAccess,
},
{
key: "routing-groups",
key: "routing",
page: "routing-groups",
label: "Routing Groups",
label: "Routing",
icon: <BranchesOutlined />,
roles: rolesWithWriteAccess,
children: [
{
key: "routing-groups",
page: "routing-groups",
label: "Routing Groups",
icon: <BranchesOutlined />,
},
{
key: "model-rollout",
page: "model-rollout",
label: (
<span className="flex items-center gap-2">
Model Rollout <NewBadge />
</span>
),
icon: <DeploymentUnitOutlined />,
},
],
},
{
key: "agents",
@ -522,6 +541,17 @@ const Sidebar: React.FC<SidebarProps> = ({ 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 (
<Layout>
<Sider
@ -556,7 +586,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
<Menu
mode="inline"
selectedKeys={[selectedMenuKey]}
defaultOpenKeys={[]}
defaultOpenKeys={openKeys}
inlineCollapsed={collapsed}
className="custom-sidebar-menu"
style={{

View file

@ -0,0 +1,262 @@
"use client";
import React, { useEffect, useState } from "react";
import {
Button,
InputNumber,
Popconfirm,
Select,
Slider,
Switch,
Tag,
} from "antd";
import {
ArrowRightOutlined,
DeleteOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
export interface RolloutConfig {
id: string;
liveModel: string;
candidateModel: string;
canaryPercent: number;
autoRollback: boolean;
errorThreshold: number;
}
const ROLLOUT_STORAGE_KEY = "litellm_model_rollout_configs";
export function getRolloutConfigs(): RolloutConfig[] {
try {
const raw = localStorage.getItem(ROLLOUT_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function findRolloutForModel(model: string): RolloutConfig | null {
const configs = getRolloutConfigs();
return configs.find(
(c) =>
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<ModelRolloutProps> = ({ accessToken }) => {
const [models, setModels] = useState<ModelGroup[]>([]);
const [configs, setConfigs] = useState<RolloutConfig[]>([]);
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<RolloutConfig>) => {
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 (
<div className="w-full max-w-screen p-6 overflow-x-hidden box-border">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-semibold">Model Rollout</h1>
<span className="text-sm text-gray-500">
Manage A/B tests across your models &middot; {configs.length} active
</span>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={addNew}>
New A/B Test
</Button>
</div>
{configs.length === 0 && (
<div className="bg-white rounded-lg shadow p-12 text-center text-gray-400">
No rollout tests configured. Click &ldquo;New A/B Test&rdquo; to get started.
</div>
)}
<div className="flex flex-col gap-4">
{configs.map((config) => {
const status = statusFor(config);
const livePercent = 100 - config.canaryPercent;
return (
<div key={config.id} className="bg-white rounded-lg shadow">
{/* Header */}
<div className="px-6 py-4 border-b flex items-center justify-between">
<div className="flex items-center gap-3">
<Tag
color="green"
style={{ fontSize: 13, padding: "2px 10px", borderRadius: 12, margin: 0 }}
>
{config.liveModel || "Select live model"}
</Tag>
<ArrowRightOutlined style={{ color: "#bbb", fontSize: 12 }} />
<Tag
color="blue"
style={{ fontSize: 13, padding: "2px 10px", borderRadius: 12, margin: 0 }}
>
{config.candidateModel || "Select candidate"}
</Tag>
<Tag color={status.color}>{status.label}</Tag>
</div>
<Popconfirm
title="Delete this A/B test?"
onConfirm={() => remove(config.id)}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</div>
{/* Body */}
<div className="p-6">
<div className="grid grid-cols-3 gap-6 mb-4">
<div>
<label className="block font-medium text-sm mb-2">Live Model</label>
<Select
value={config.liveModel || undefined}
placeholder="Select live model"
onChange={(v) => update(config.id, { liveModel: v })}
options={modelOptions}
style={{ width: "100%" }}
showSearch
/>
</div>
<div>
<label className="block font-medium text-sm mb-2">Candidate Model</label>
<Select
value={config.candidateModel || undefined}
placeholder="Select candidate"
onChange={(v) => update(config.id, { candidateModel: v })}
options={modelOptions}
style={{ width: "100%" }}
showSearch
/>
</div>
<div>
<label className="block font-medium text-sm mb-2">Auto-Rollback</label>
<div className="flex items-center gap-3">
<Switch
checked={config.autoRollback}
onChange={(v) => update(config.id, { autoRollback: v })}
size="small"
/>
<span className="text-sm text-gray-500">at</span>
<InputNumber
min={1}
max={100}
value={config.errorThreshold}
onChange={(v) => update(config.id, { errorThreshold: v ?? 5 })}
size="small"
style={{ width: 60 }}
/>
<span className="text-sm text-gray-500">% errors</span>
</div>
</div>
</div>
{/* Traffic split */}
<div>
<label className="block font-medium text-sm mb-1">
Traffic Split
<span className="ml-2 font-normal text-gray-400">
{livePercent}% live / {config.canaryPercent}% canary
</span>
</label>
<div className="flex items-center gap-4">
<div className="flex-1">
<Slider
min={0}
max={100}
value={config.canaryPercent}
onChange={(v) => update(config.id, { canaryPercent: v })}
tooltip={{ formatter: (v) => `${v}% canary` }}
/>
</div>
<div className="flex gap-1 shrink-0">
{CANARY_PRESETS.map((p) => (
<Button
key={p}
size="small"
type={config.canaryPercent === p ? "primary" : "default"}
onClick={() => update(config.id, { canaryPercent: p })}
style={{ minWidth: 40 }}
>
{p}%
</Button>
))}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default ModelRollout;

View file

@ -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<ChatUIProps> = ({
const [maxTokens, setMaxTokens] = useState<number>(2048);
const [useAdvancedParams, setUseAdvancedParams] = useState<boolean>(false);
const [mockTestFallbacks, setMockTestFallbacks] = useState<boolean>(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<ChatUIProps> = ({
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<ChatUIProps> = ({
: (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<ChatUIProps> = ({
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<ChatUIProps> = ({
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<ChatUIProps> = ({
</div>
)}
{/* Model Rollout indicator */}
{lastRolloutInfo && (
<div
style={{
marginTop: 8,
padding: "8px 10px",
borderRadius: 8,
background: lastRolloutInfo.requestedModel !== lastRolloutInfo.actualModel ? "#e6f7ff" : "#f6ffed",
border: lastRolloutInfo.requestedModel !== lastRolloutInfo.actualModel ? "1px solid #91d5ff" : "1px solid #b7eb8f",
fontSize: 12,
}}
>
<div style={{ fontWeight: 600, marginBottom: 2, color: "#333" }}>
<SwapOutlined style={{ marginRight: 4 }} /> Model Rollout Active
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ color: "#666" }}>Requested:</span>
<span style={{ fontWeight: 500 }}>{lastRolloutInfo.requestedModel}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ color: "#666" }}>Sent to:</span>
<span style={{ fontWeight: 600, color: lastRolloutInfo.requestedModel !== lastRolloutInfo.actualModel ? "#1677ff" : "#52c41a" }}>
{lastRolloutInfo.actualModel}
</span>
</div>
</div>
)}
{/* Agent Selector - shown ONLY for A2A Agents endpoint */}
{endpointType === EndpointType.A2A_AGENTS && (
<div>
@ -1891,9 +1939,21 @@ const ChatUI: React.FC<ChatUIProps> = ({
</div>
<strong className="text-sm capitalize">{message.role}</strong>
{message.role === "assistant" && message.model && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-normal">
{message.model}
</span>
message.model.includes("(rollout from") ? (
<span className="flex items-center gap-1.5">
<span className="text-xs px-2 py-0.5 rounded font-normal" style={{ background: "#e6f7ff", color: "#1677ff", border: "1px solid #91d5ff" }}>
{message.model.split(" (rollout from")[0]}
</span>
<span className="text-xs text-gray-400">via rollout</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-50 text-gray-400 font-normal line-through" style={{ fontSize: 10 }}>
{message.model.match(/\(rollout from (.+)\)/)?.[1] ?? ""}
</span>
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-normal">
{message.model}
</span>
)
)}
</div>
{message.reasoningContent && <ReasoningContent reasoningContent={message.reasoningContent} />}

View file

@ -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<number>;
simTrace?: SimTrace | null;
onToggleFail?: (index: number) => void;
}
const DEPLOY_COLORS = ["#3b82f6", "#6366f1", "#0ea5e9"];
@ -72,43 +80,121 @@ const OutputCard: React.FC<{ subtitle: string }> = ({ subtitle }) => (
</div>
);
const ModelNodeCard: React.FC<{ label: string; name: string; subName: string }> = ({ label, name, subName }) => (
<div style={{ border: "1px solid #e5e7eb", borderRadius: 12, padding: "14px 18px", backgroundColor: "#fff", width: "100%", maxWidth: 340, position: "relative" }}>
<div style={{ position: "absolute", top: 12, right: 14 }}><StatusDot /></div>
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>{label}</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div style={{ width: 32, height: 32, borderRadius: "50%", backgroundColor: "#eff6ff", border: "1.5px solid #bfdbfe", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, fontSize: 13, fontWeight: 700, color: "#3b82f6" }}>
{name.charAt(0).toUpperCase()}
type NodeStatus = "idle" | "failing" | "sim-fail" | "sim-success" | "sim-skipped";
const FailDot: React.FC = () => (
<div style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "#ef4444", flexShrink: 0 }} />
);
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 (
<div
onClick={onClick}
style={{
border: `1.5px solid ${borderColor}`, borderRadius: 12, padding: "14px 18px",
backgroundColor: bg, width: "100%", maxWidth: 340, position: "relative",
cursor: onClick ? "pointer" : "default", opacity: isSkipped ? 0.45 : 1,
transition: "all 0.2s",
}}
>
<div style={{ position: "absolute", top: 12, right: 14 }}>
{isFailing ? <FailDot /> : <StatusDot />}
</div>
{onClick && (
<div style={{ position: "absolute", top: 12, right: 30, fontSize: 9, color: "#9ca3af" }}>
{isFailing ? "click: unfail" : "click: fail"}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>{name}</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{subName}</div>
)}
<div style={{ fontSize: 10, fontWeight: 700, color: "#9ca3af", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>{label}</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div style={{ width: 32, height: 32, borderRadius: "50%", backgroundColor: "#eff6ff", border: "1.5px solid #bfdbfe", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, fontSize: 13, fontWeight: 700, color: "#3b82f6" }}>
{name.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>{name}</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{subName}</div>
</div>
</div>
</div>
{isSuccess && (
<div style={{ marginTop: 8, fontSize: 11, fontWeight: 600, color: "#16a34a", display: "flex", alignItems: "center", gap: 4 }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2.5"><path d="M20 6L9 17l-5-5" /></svg>
Request handled
</div>
)}
{status === "sim-fail" && (
<div style={{ marginTop: 8, fontSize: 11, fontWeight: 600, color: "#ef4444", display: "flex", alignItems: "center", gap: 4 }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2.5"><path d="M18 6L6 18M6 6l12 12" /></svg>
Failed — trying next
</div>
)}
</div>
</div>
);
);
};
const FailoverFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }) => (
<div className="flex flex-col items-center">
<TriggerCard subtitle="→ first available" />
{deployments.map((dep, i) => {
const label = i === 0 ? "PRIMARY" : `FALLBACK-${i}`;
const shortName = dep.model_name.split("/").pop() || dep.model_name;
return (
<React.Fragment key={dep.model_id + "-" + i}>
{i === 0 ? <VerticalLine /> : <FailConnector />}
<ModelNodeCard label={label} name={shortName} subName={dep.model_name} />
</React.Fragment>
);
})}
<VerticalLine />
<OutputCard subtitle="first successful result" />
</div>
);
const FailoverFlow: React.FC<{
deployments: Deployment[];
failingIndices?: Set<number>;
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 (
<div className="flex flex-col items-center">
<TriggerCard subtitle="→ first available" />
{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 (
<React.Fragment key={dep.model_id + "-" + i}>
{i === 0 ? <VerticalLine /> : <FailConnector />}
<ModelNodeCard
label={label} name={shortName} subName={dep.model_name}
status={nodeStatus}
onClick={onToggleFail ? () => onToggleFail(i) : undefined}
/>
</React.Fragment>
);
})}
<VerticalLine />
<OutputCard subtitle={simTrace?.selectedIndex !== null && simTrace?.selectedIndex !== undefined
? `handled by ${deployments[simTrace.selectedIndex]?.model_name.split("/").pop()}`
: "first successful result"
} />
</div>
);
};
const WeightedFlow: React.FC<{
deployments: Deployment[];
failingIndices?: Set<number>;
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 (
<div
key={dep.model_id + "-row-" + i}
className="flex items-center justify-between"
style={{ padding: "8px 12px", backgroundColor: "#f9fafb", borderRadius: 8, border: "1px solid #f3f4f6" }}
onClick={onToggleFail ? () => 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",
}}
>
<div className="flex items-center gap-2.5">
<div style={{ width: 28, height: 28, borderRadius: "50%", backgroundColor: color + "18", border: `1.5px solid ${color}44`, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, fontSize: 11, fontWeight: 700, color }}>
{shortName.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>{shortName}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
{shortName}
{isSimSuccess && <span style={{ marginLeft: 6, fontSize: 10, color: "#16a34a" }}>✓ selected</span>}
{isSimFail && <span style={{ marginLeft: 6, fontSize: 10, color: "#ef4444" }}>✗ failed</span>}
</div>
<div style={{ fontSize: 10, color: "#9ca3af" }}>{dep.model_name}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span style={{ fontSize: 13, fontWeight: 700, color }}>{pct}%</span>
<div style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "#22c55e", flexShrink: 0 }} />
{isFailing || isSimFail ? <FailDot /> : <div style={{ width: 8, height: 8, borderRadius: "50%", backgroundColor: "#22c55e", flexShrink: 0 }} />}
</div>
</div>
);
@ -230,7 +335,10 @@ const ComplexityFlow: React.FC<{ deployments: Deployment[] }> = ({ deployments }
</div>
);
const SmartRoutingFlow: React.FC<{ deployments: Deployment[]; strategy: string }> = ({ deployments, strategy }) => {
const SmartRoutingFlow: React.FC<{
deployments: Deployment[]; strategy: string;
failingIndices?: Set<number>; simTrace?: SimTrace | null; onToggleFail?: (index: number) => void;
}> = ({ deployments, strategy, failingIndices, simTrace, onToggleFail }) => {
const meta = STRATEGY_META[strategy] ?? STRATEGY_META["simple-shuffle"];
return (
<div className="flex flex-col items-center">
@ -243,25 +351,48 @@ const SmartRoutingFlow: React.FC<{ deployments: Deployment[]; strategy: string }
<div className="flex flex-col gap-2">
{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 (
<div key={dep.model_id + "-" + i} className="flex items-center justify-between" style={{ padding: "8px 12px", backgroundColor: "#f9fafb", borderRadius: 8, border: "1px solid #f3f4f6" }}>
<div
key={dep.model_id + "-" + i}
className="flex items-center justify-between"
onClick={onToggleFail ? () => 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",
}}
>
<div className="flex items-center gap-2">
<div style={{ width: 28, height: 28, borderRadius: "50%", backgroundColor: "#eff6ff", border: "1.5px solid #bfdbfe", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, fontSize: 11, fontWeight: 700, color: "#3b82f6" }}>
{shortName.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>{shortName}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
{shortName}
{isSimSuccess && <span style={{ marginLeft: 6, fontSize: 10, color: "#16a34a" }}>✓ selected</span>}
</div>
<div style={{ fontSize: 10, color: "#9ca3af" }}>{dep.model_name}</div>
</div>
</div>
<StatusDot />
{isFailing ? <FailDot /> : <StatusDot />}
</div>
);
})}
</div>
</div>
<VerticalLine />
<OutputCard subtitle={meta.output} />
<OutputCard subtitle={simTrace?.selectedIndex !== null && simTrace?.selectedIndex !== undefined
? `handled by ${deployments[simTrace.selectedIndex]?.model_name.split("/").pop()}`
: meta.output
} />
</div>
);
};
@ -271,26 +402,27 @@ const EmptyFlow: React.FC = () => (
<TriggerCard subtitle="→ select a strategy" />
<VerticalLine />
<div style={{ border: "1px dashed #d1d5db", borderRadius: 12, padding: "28px 20px", width: "100%", maxWidth: 340, textAlign: "center" }}>
<span style={{ fontSize: 13, color: "#9ca3af" }}>Add deployments to see the flow</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>Add models to see the flow</span>
</div>
<VerticalLine />
<OutputCard subtitle="waiting for configuration" />
</div>
);
export default function RoutingFlowVisualization({ strategy, deployments }: RoutingFlowVisualizationProps) {
export default function RoutingFlowVisualization({ strategy, deployments, failingIndices, simTrace, onToggleFail }: RoutingFlowVisualizationProps) {
if (strategy === "complexity-router") return <ComplexityFlow deployments={deployments} />;
if (deployments.length === 0) return <EmptyFlow />;
const simProps = { failingIndices, simTrace, onToggleFail };
switch (strategy) {
case "priority-failover": return <FailoverFlow deployments={deployments} />;
case "weighted": return <WeightedFlow deployments={deployments} />;
case "priority-failover": return <FailoverFlow deployments={deployments} {...simProps} />;
case "weighted": return <WeightedFlow deployments={deployments} {...simProps} />;
case "simple-shuffle":
case "latency-based-routing":
case "usage-based-routing-v2":
case "cost-based-routing":
case "least-busy":
return <SmartRoutingFlow deployments={deployments} strategy={strategy} />;
default: return <SmartRoutingFlow deployments={deployments} strategy={strategy} />;
return <SmartRoutingFlow deployments={deployments} strategy={strategy} {...simProps} />;
default: return <SmartRoutingFlow deployments={deployments} strategy={strategy} {...simProps} />;
}
}

View file

@ -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<Record<string, string>>({ SIMPLE: "", MEDIUM: "", COMPLEX: "", REASONING: "" });
const [failingIndices, setFailingIndices] = useState<Set<number>>(new Set());
const [simTrace, setSimTrace] = useState<SimTrace | null>(null);
const dragItem = useRef<number | null>(null);
const dragOverItem = useRef<number | null>(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({
) : (
<>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-semibold text-gray-400 tracking-wide uppercase">2 &middot; Deployments</div>
<div className="text-xs font-semibold text-gray-400 tracking-wide uppercase">2 &middot; Model Group</div>
<button
onClick={addEmptySlot}
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700 bg-transparent border-none cursor-pointer p-0 font-medium"
@ -516,37 +580,45 @@ export default function RoutingGroupBuilder({
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#374151" }}>Flow Preview</span>
<div className="flex items-center gap-2">
<div className="flex" style={{ border: "1px solid #e5e7eb", borderRadius: 6, overflow: "hidden" }}>
{["1x", "2x", "5x"].map((label) => (
<button
key={label}
className="text-xs px-2.5 py-1 bg-white hover:bg-gray-50 border-none cursor-pointer text-gray-500"
style={{ borderRight: label !== "5x" ? "1px solid #e5e7eb" : "none" }}
>
{label}
</button>
))}
</div>
<button className="flex items-center gap-1 text-xs text-gray-500 px-2.5 py-1 border border-gray-200 rounded-md bg-white hover:bg-gray-50 cursor-pointer">
{failingIndices.size > 0 && (
<span className="text-xs text-red-500 font-medium">{failingIndices.size} failing</span>
)}
<button
onClick={resetSimulation}
className="flex items-center gap-1 text-xs text-gray-500 px-2.5 py-1 border border-gray-200 rounded-md bg-white hover:bg-gray-50 cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 4v6h6M23 20v-6h-6" /><path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
</svg>
Reset
</button>
<button
onClick={runSimulation}
disabled={deployments.length === 0}
className="flex items-center gap-1 text-xs text-white px-3 py-1.5 border-none rounded-md cursor-pointer font-semibold"
style={{ backgroundColor: "#111827" }}
style={{ backgroundColor: deployments.length === 0 ? "#9ca3af" : "#111827" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="#fff" stroke="none"><polygon points="6,3 20,12 6,21" /></svg>
Run
Send Request
</button>
</div>
</div>
{/* Flow canvas */}
<div style={{ flex: 1, overflowY: "auto", padding: "24px", display: "flex", justifyContent: "center" }}>
<div style={{ flex: 1, overflowY: "auto", padding: "24px", display: "flex", flexDirection: "column", alignItems: "center" }}>
{strategy !== "complexity-router" && deployments.length > 0 && (
<div style={{ fontSize: 11, color: "#9ca3af", marginBottom: 12, textAlign: "center" }}>
Click any deployment to toggle failure &middot; then press <strong>Send Request</strong> to simulate
</div>
)}
<div style={{ maxWidth: 520, width: "100%" }}>
<RoutingFlowVisualization strategy={strategy} deployments={deployments} />
<RoutingFlowVisualization
strategy={strategy}
deployments={deployments}
failingIndices={failingIndices}
simTrace={simTrace}
onToggleFail={toggleFail}
/>
</div>
</div>

View file

@ -132,6 +132,9 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
</Card>
</div>
{/* Routing Info */}
<RoutingSection metadata={metadata} modelGroup={logEntry.model_group} selectedModel={logEntry.model} selectedModelId={logEntry.model_id} />
{/* Metrics */}
<MetricsSection logEntry={logEntry} metadata={metadata} />
@ -499,6 +502,193 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
);
}
const STRATEGY_LABELS: Record<string, string> = {
"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<string, any>;
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 (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
<Card
title={
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span>Routing</span>
{isRoutingGroup && (
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>Routing Group</Tag>
)}
</div>
}
size="small"
bordered={false}
style={{ marginBottom: 0 }}
>
<Descriptions column={2} size="small">
<Descriptions.Item label="Model Group">
<Text strong>{modelGroup}</Text>
</Descriptions.Item>
<Descriptions.Item label="Selected">
<Text>{selectedModel}</Text>
{selectedModelId && (
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>
({selectedModelId.slice(0, 8)}…)
</Text>
)}
</Descriptions.Item>
{routingStrategy && (
<Descriptions.Item label="Strategy">
<Tag style={{ margin: 0 }}>{strategyLabel}</Tag>
</Descriptions.Item>
)}
{modelGroupSize != null && (
<Descriptions.Item label="Candidates">
{modelGroupSize} deployment{modelGroupSize !== 1 ? "s" : ""}
</Descriptions.Item>
)}
</Descriptions>
{/* Candidate list */}
{sortedCandidates.length > 0 && (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 8, fontWeight: 500 }}>
{isOrdered ? "Fallback Order" : "Candidates Considered"}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{sortedCandidates.map((c, idx) => {
const isSelected = c.model_id === selectedModelId;
const label = c.litellm_model || c.model_name || c.model_id || "unknown";
return (
<div key={c.model_id || idx}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
borderRadius: 8,
backgroundColor: isSelected ? "#eff6ff" : "#fafafa",
border: isSelected ? "1px solid #bfdbfe" : "1px solid #f3f4f6",
}}
>
{isOrdered && (
<div style={{
width: 22, height: 22, borderRadius: "50%",
backgroundColor: isSelected ? "#3b82f6" : "#e5e7eb",
color: isSelected ? "#fff" : "#6b7280",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 11, fontWeight: 600, flexShrink: 0,
}}>
{idx + 1}
</div>
)}
{!isOrdered && (
<div style={{
width: 6, height: 6, borderRadius: "50%",
backgroundColor: isSelected ? "#3b82f6" : "#d1d5db",
flexShrink: 0,
}} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 13, fontWeight: isSelected ? 600 : 400 }}>
{label}
</Text>
{c.model_id && (
<Text type="secondary" style={{ fontSize: 10, marginLeft: 6 }}>
{c.model_id.slice(0, 8)}…
</Text>
)}
</div>
{isSelected && (
<Tag color="green" style={{ margin: 0, fontSize: 10 }}>selected</Tag>
)}
{isOrdered && !isSelected && idx === 0 && (
<Tag style={{ margin: 0, fontSize: 10, color: "#9ca3af", borderColor: "#e5e7eb" }}>primary</Tag>
)}
</div>
{/* Connector line between items */}
{isOrdered && idx < sortedCandidates.length - 1 && (
<div style={{ display: "flex", alignItems: "center", paddingLeft: 22, height: 16 }}>
<div style={{ width: 1, height: "100%", backgroundColor: "#e5e7eb", marginLeft: 0 }} />
<span style={{ fontSize: 9, color: "#d1d5db", marginLeft: 8 }}>↓ fallback</span>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Explanation */}
{modelGroupSize != null && modelGroupSize > 1 && (
<div style={{ marginTop: 12, padding: "8px 12px", backgroundColor: "#f9fafb", borderRadius: 8, border: "1px solid #f3f4f6" }}>
<div style={{ fontSize: 12, color: "#374151", lineHeight: 1.5 }}>
{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 <strong>{selectedModel}</strong> 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.</>
)}
</div>
</div>
)}
</Card>
</div>
);
}
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
return (
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">

View file

@ -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;