mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
UI FIXES
This commit is contained in:
parent
4611f8f889
commit
259e10b2fc
9 changed files with 826 additions and 74 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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 · {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 “New A/B Test” 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;
|
||||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 · Deployments</div>
|
||||
<div className="text-xs font-semibold text-gray-400 tracking-wide uppercase">2 · 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 · 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue