- {ids.map((id) => (
+ {items.map(({ id, name }) => (
- {id}
+ {name ? (
+
+ {name}
+
+ ) : (
+ {id}
+ )}
))}
@@ -34,6 +48,23 @@ function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: stri
);
}
+function ResourceBadge({
+ resource: { id, name },
+ href,
+ fallback,
+}: {
+ resource: AccessGroupResource;
+ href: string;
+ fallback: (id: string) => string;
+}) {
+ const badge = (
+
+ {name ?? fallback(id)}
+
+ );
+ return name ?
{badge} : badge;
+}
+
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
@@ -61,14 +92,14 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
);
}
- const modelIds = accessGroup.access_model_names ?? [];
- const mcpServerIds = accessGroup.access_mcp_server_ids ?? [];
- const agentIds = accessGroup.access_agent_ids ?? [];
- const keyIds = accessGroup.assigned_key_ids ?? [];
- const teamIds = accessGroup.assigned_team_ids ?? [];
+ const models = accessGroup.access_model_names.map((id) => ({ id, name: null }));
+ const mcpServers = accessGroup.access_mcp_servers;
+ const agents = accessGroup.access_agents;
+ const keys = accessGroup.assigned_keys;
+ const teams = accessGroup.assigned_teams;
- const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
- const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
+ const displayedKeys = showAllKeys ? keys : keys.slice(0, MAX_PREVIEW);
+ const displayedTeams = showAllTeams ? teams : teams.slice(0, MAX_PREVIEW);
return (
@@ -129,23 +160,21 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
Attached Keys
- {keyIds.length}
+ {keys.length}
- {keyIds.length > MAX_PREVIEW && (
+ {keys.length > MAX_PREVIEW && (
)}
- {keyIds.length > 0 ? (
+ {keys.length > 0 ? (
- {displayedKeys.map((id) => (
-
- {id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
-
+ {displayedKeys.map((key) => (
+
))}
) : (
@@ -159,23 +188,21 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
Attached Teams
- {teamIds.length}
+ {teams.length}
- {teamIds.length > MAX_PREVIEW && (
+ {teams.length > MAX_PREVIEW && (
)}
- {teamIds.length > 0 ? (
+ {teams.length > 0 ? (
- {displayedTeams.map((id) => (
-
- {id}
-
+ {displayedTeams.map((team) => (
+ id} />
))}
) : (
@@ -192,27 +219,27 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
Models
- {modelIds.length}
+ {models.length}
MCP Servers
- {mcpServerIds.length}
+ {mcpServers.length}
Agents
- {agentIds.length}
+ {agents.length}
-
+
-
+
-
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx
index 2e65be36796..bd77ad8e897 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx
@@ -42,6 +42,10 @@ const accessGroup: AccessGroupResponse = {
access_agent_ids: ["agent-1"],
assigned_team_ids: [],
assigned_key_ids: [],
+ access_mcp_servers: [{ id: "srv-1", name: "Server One" }],
+ access_agents: [{ id: "agent-1", name: "Agent One" }],
+ assigned_teams: [],
+ assigned_keys: [],
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-02T00:00:00Z",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx
index 63ff0f4100f..12d3d773c1f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx
@@ -15,6 +15,10 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_agent_ids: ["a1"],
assigned_team_ids: [],
assigned_key_ids: [],
+ access_mcp_servers: [{ id: "s1", name: "Server One" }],
+ access_agents: [{ id: "a1", name: "Agent One" }],
+ assigned_teams: [],
+ assigned_keys: [],
created_at: "2024-01-15T10:00:00Z",
created_by: "user-1",
updated_at: "2024-01-20T12:00:00Z",
@@ -29,6 +33,10 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_agent_ids: [],
assigned_team_ids: [],
assigned_key_ids: [],
+ access_mcp_servers: [],
+ access_agents: [],
+ assigned_teams: [],
+ assigned_keys: [],
created_at: "2024-01-10T09:00:00Z",
created_by: null,
updated_at: "2024-01-12T11:00:00Z",
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx
index a1de608d0bb..64363da9933 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx
@@ -104,6 +104,7 @@ const job = (overrides: Partial = {}): ShadowEvalJob => ({
status: "running",
router_name: "claude-auto",
router_names: ["claude-auto"],
+ models: [],
direction: "forward",
baseline_model: null,
judge_model: "anthropic/claude-sonnet-5",
@@ -450,6 +451,7 @@ describe("ShadowEvalSection", () => {
api_key_ids: ["hash-alpha", "hash-beta"],
team_ids: [],
user_ids: [],
+ models: [],
router_names: ["gpt-auto"],
direction: "forward",
shadow_percentage: 10,
@@ -479,6 +481,7 @@ describe("ShadowEvalSection", () => {
api_key_ids: [],
team_ids: ["team-eng"],
user_ids: [],
+ models: [],
router_names: ["gpt-auto"],
direction: "forward",
shadow_percentage: 10,
@@ -489,15 +492,41 @@ describe("ShadowEvalSection", () => {
expect(start.mutate).toHaveBeenCalledWith(expectedBody);
});
+ it("narrows a job to the picked model groups and shows the scope on the job headline", async () => {
+ const user = userEvent.setup();
+ const { start } = mockHooks({});
+ render();
+
+ await user.click(screen.getByPlaceholderText("Search teams by alias"));
+ const teamList = await screen.findByTestId("paginated-multi-select-list");
+ await user.click(within(teamList).getByText("engineering"));
+ await chooseSelectOption(user, screen.getByPlaceholderText("Every model the targets use"), "prod-claude");
+ await chooseSelectOption(user, screen.getByPlaceholderText("Select up to 4 auto-routers"), "gpt-auto");
+ await user.click(screen.getByPlaceholderText("Select a judge model"));
+ await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ }));
+ await user.click(screen.getByText("Start shadow eval"));
+
+ expect(start.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({ team_ids: ["team-eng"], models: ["prod-claude"] }),
+ );
+
+ const scoped = job({ models: ["prod-claude", "prod-haiku"] });
+ mockHooks({ jobs: [scoped], detailsById: { "job-1": scoped } });
+ render();
+ expect(screen.getByText("prod-claude, prod-haiku")).toBeInTheDocument();
+ });
+
it("requires a baseline model in reverse mode and submits it, while forward mode never shows the picker", async () => {
const user = userEvent.setup();
const { start } = mockHooks({});
render();
expect(screen.queryByPlaceholderText("Select a baseline model")).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Every model the targets use")).toBeInTheDocument();
await user.click(screen.getByText("Adoption check: key's traffic vs the router"));
await user.click(await screen.findByText("Regression check: router's picks vs a baseline"));
+ expect(screen.queryByPlaceholderText("Every model the targets use")).not.toBeInTheDocument();
await user.click(screen.getByPlaceholderText("Search keys by alias"));
const keyList = await screen.findByTestId("paginated-multi-select-list");
await user.click(within(keyList).getByText("prod-alpha"));
@@ -516,6 +545,7 @@ describe("ShadowEvalSection", () => {
api_key_ids: ["hash-alpha"],
team_ids: [],
user_ids: [],
+ models: [],
router_names: ["gpt-auto"],
direction: "reverse",
baseline_model: "prod-claude",
@@ -551,6 +581,7 @@ describe("ShadowEvalSection", () => {
api_key_ids: ["hash-alpha"],
team_ids: [],
user_ids: [],
+ models: [],
router_names: ["gpt-auto", "claude-auto"],
direction: "forward",
shadow_percentage: 10,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx
index c66d74074c2..ea11879d971 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx
@@ -87,17 +87,25 @@ const targetStatus = (job: ShadowEvalJob, target: ShadowEvalJobTarget): string =
const jobRouters = (job: ShadowEvalJob): string => (job.router_names ?? [job.router_name]).join(", ");
+const jobModelScope = (job: ShadowEvalJob): React.ReactNode =>
+ job.models && job.models.length > 0 ? (
+ <>
+ {" "}
+ on {job.models.join(", ")}
+ >
+ ) : null;
+
const jobHeadline = (job: ShadowEvalJob): React.ReactNode =>
job.direction === "reverse" ? (
<>
Comparing {jobRouters(job)} to{" "}
{job.baseline_model} on {job.shadow_percentage}% of{" "}
- {shadowedTargetsLabel(job)} traffic
+ {shadowedTargetsLabel(job)} traffic{jobModelScope(job)}
>
) : (
<>
Shadowing {job.shadow_percentage}% of {shadowedTargetsLabel(job)}{" "}
- traffic via {jobRouters(job)}
+ traffic{jobModelScope(job)} via {jobRouters(job)}
>
);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx
index f96910a4ad6..2eb5fa9c945 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx
@@ -23,6 +23,7 @@ import { useStartShadowEval, type ShadowEvalJob } from "./useShadowEval";
type ShadowEvalDirection = ShadowEvalJob["direction"];
const MAX_ROUTERS = 4;
+const MAX_MODELS = 100;
const RECOMMENDED_JUDGE_MODELS = ["anthropic/claude-sonnet-5", "openai/gpt-4o", "gemini/gemini-2.5-pro"] as const;
@@ -206,6 +207,7 @@ interface StartFormValidityInputs {
apiKeyIds: string[];
teamIds: string[];
userIds: string[];
+ models: string[];
routerNames: string[];
direction: ShadowEvalDirection;
baselineModel: string;
@@ -224,7 +226,8 @@ const startFormValidity = (inputs: StartFormValidityInputs) => {
const routerCountValid = inputs.routerNames.length >= 1 && inputs.routerNames.length <= MAX_ROUTERS;
const routersMatchDirection = inputs.direction === "forward" || inputs.routerNames.length === 1;
const routersValid = routerCountValid && routersMatchDirection;
- const modelsPicked = routersValid && inputs.judgeModel !== "" && baselinePicked;
+ const scopeValid = routersValid && (inputs.direction === "reverse" || inputs.models.length <= MAX_MODELS);
+ const modelsPicked = scopeValid && inputs.judgeModel !== "" && baselinePicked;
const filled = targetsPicked && modelsPicked;
const boundsValid = percentageValid && maxBudgetValid;
const valid = Boolean(inputs.accessToken) && filled && boundsValid;
@@ -235,6 +238,7 @@ interface StartBodyInputs {
apiKeyIds: string[];
teamIds: string[];
userIds: string[];
+ models: string[];
routerNames: string[];
direction: ShadowEvalDirection;
baselineModel: string;
@@ -248,6 +252,7 @@ const buildStartBody = (inputs: StartBodyInputs) => ({
api_key_ids: inputs.apiKeyIds,
team_ids: inputs.teamIds,
user_ids: inputs.userIds,
+ models: inputs.direction === "forward" ? inputs.models : [],
router_names: inputs.routerNames,
direction: inputs.direction,
...(inputs.direction === "reverse" ? { baseline_model: inputs.baselineModel } : {}),
@@ -262,6 +267,7 @@ export const StartForm: React.FC = () => {
const [apiKeyIds, setApiKeyIds] = useState([]);
const [teamIds, setTeamIds] = useState([]);
const [userIds, setUserIds] = useState([]);
+ const [models, setModels] = useState([]);
const [routerNames, setRouterNames] = useState([]);
const [direction, setDirection] = useState("forward");
const [baselineModel, setBaselineModel] = useState("");
@@ -272,6 +278,11 @@ export const StartForm: React.FC = () => {
const { data: autoRouters } = useAutoRouters();
const judgeModelOptions = useJudgeModelOptions();
const baselineModelOptions = useBaselineModelOptions();
+ const configuredGroups = usePlainModelGroups();
+ const modelOptions = useMemo(
+ () => [...configuredGroups].toSorted((a, b) => a.localeCompare(b)).map((name) => ({ label: name, value: name })),
+ [configuredGroups],
+ );
const start = useStartShadowEval();
const routerOptions = useMemo(() => {
@@ -286,6 +297,7 @@ export const StartForm: React.FC = () => {
apiKeyIds,
teamIds,
userIds,
+ models,
routerNames,
direction,
baselineModel,
@@ -299,6 +311,7 @@ export const StartForm: React.FC = () => {
apiKeyIds,
teamIds,
userIds,
+ models,
routerNames,
direction,
baselineModel,
@@ -344,6 +357,22 @@ export const StartForm: React.FC = () => {
+ {direction === "forward" && (
+
+
+ {models.length > MAX_MODELS ? (
+ Pick at most {MAX_MODELS} models
+ ) : (
+ Narrows every target above to requests for these models
+ )}
+
+ )}
{
timeout_ms: 1,
});
});
+
+ it.each([{}, { system_prompt: "x" }])("normalizeClassifierLlmConfig carries vision through %o", (extra) => {
+ const base = { model: "m", timeout_ms: 1, ...extra };
+ const vision = { enabled: true, max_images: 2 };
+ expect(normalizeClassifierLlmConfig({ ...base, vision })).toEqual({ ...base, vision });
+ });
});
describe("tier labels", () => {
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
index 1a395c38779..d7974484970 100644
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -1,4 +1,7 @@
import { KeywordTierRule } from "./KeywordTierRules";
+
+type ClassifierLLMConfigWire = ClassifierLLMConfig & { vision?: { enabled?: boolean; max_images?: number } };
+
import type { ModelGroup } from "../llm_calls/fetch_models";
import {
type CustomTierSet,
@@ -61,7 +64,8 @@ export const normalizeClassifierLlmConfig = ({
reasoning_effort,
classification_rubric,
system_prompt,
-}: ClassifierLLMConfig): ClassifierLLMConfig =>
+ vision,
+}: ClassifierLLMConfigWire): ClassifierLLMConfigWire =>
system_prompt?.trim()
? {
model,
@@ -69,6 +73,7 @@ export const normalizeClassifierLlmConfig = ({
...(circuit_breaker_enabled !== undefined && { circuit_breaker_enabled }),
...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }),
...(reasoning_effort && { reasoning_effort }),
+ ...(vision && { vision }),
system_prompt,
}
: {
@@ -78,6 +83,7 @@ export const normalizeClassifierLlmConfig = ({
...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }),
...(reasoning_effort && { reasoning_effort }),
...(classification_rubric && { classification_rubric }),
+ ...(vision && { vision }),
};
interface ScorerKnobInputs {
@@ -324,7 +330,7 @@ export const getSemanticConfigError = ({
};
interface CustomTierWireFieldInputs {
- classifierLlmConfig: ClassifierLLMConfig | undefined;
+ classifierLlmConfig: ClassifierLLMConfigWire | undefined;
planModeMinTierId: string | undefined;
classificationPrompt: string | undefined;
classificationExamples: string | undefined;
@@ -356,6 +362,7 @@ export const customTierWireFields = (
circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
}),
...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
+ ...(classifierLlmConfig.vision && { vision: classifierLlmConfig.vision }),
},
}),
session_affinity: false,
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 6f60905d4c1..0f9d937c3b2 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -1264,7 +1264,10 @@ export interface paths {
* A target is a virtual key, a team, or a user. Team and user targets match on the
* identity every request resolves to at auth time, so they cover JWT-authenticated
* traffic, which presents no virtual key; a user target samples that user's traffic
- * across all their teams, whether it arrives on a JWT or a key they own.
+ * across all their teams, whether it arrives on a JWT or a key they own. models narrows
+ * every target to requests for those model groups, so a user plus one model samples that
+ * user's traffic on that model across every key they own; it is forward-only, since a
+ * reverse job already samples exactly the traffic its own router served.
*
* A forward job answers whether the targets should adopt router_name: it samples the
* requests the router did not serve and duplicates them through it. A reverse job
@@ -22764,22 +22767,40 @@ export interface components {
/** Spend */
spend?: number | null;
};
+ /**
+ * AccessGroupResource
+ * @description A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias.
+ */
+ AccessGroupResource: {
+ /** Id */
+ id: string;
+ /** Name */
+ name: string | null;
+ };
/** AccessGroupResponse */
AccessGroupResponse: {
/** Access Agent Ids */
access_agent_ids: string[];
+ /** Access Agents */
+ access_agents: components["schemas"]["AccessGroupResource"][];
/** Access Group Id */
access_group_id: string;
/** Access Group Name */
access_group_name: string;
/** Access Mcp Server Ids */
access_mcp_server_ids: string[];
+ /** Access Mcp Servers */
+ access_mcp_servers: components["schemas"]["AccessGroupResource"][];
/** Access Model Names */
access_model_names: string[];
/** Assigned Key Ids */
assigned_key_ids: string[];
+ /** Assigned Keys */
+ assigned_keys: components["schemas"]["AccessGroupResource"][];
/** Assigned Team Ids */
assigned_team_ids: string[];
+ /** Assigned Teams */
+ assigned_teams: components["schemas"]["AccessGroupResource"][];
/**
* Created At
* Format: date-time
@@ -25305,6 +25326,30 @@ export interface components {
* @default 3000
*/
timeout_ms: number;
+ /** @description Whether the classifier sees images on the request, and how many */
+ vision?: components["schemas"]["ClassifierVisionConfig"];
+ };
+ /**
+ * ClassifierVisionConfig
+ * @description Whether the LLM classifier sees the images on the request it is classifying.
+ *
+ * Off by default because images cost far more than the text ask they arrive with, and the
+ * classifier runs on every request. A turn whose complexity lives in the image ("what is wrong in
+ * this stack trace screenshot") is invisible to a text-only classifier, which is what this buys.
+ */
+ ClassifierVisionConfig: {
+ /**
+ * Enabled
+ * @description Forward image content to the classifier. Requires a classifier model declared supports_vision, on the deployment's model_info or in the model cost map; images stay stripped otherwise, so a classifier that cannot read them is never sent one. Declare model_info.supports_vision on the deployment to enable a model the cost map does not describe. Only inline data: URIs are forwarded. A request whose images are http(s) URLs still classifies on its text alone, because some providers fetch such a URL from the proxy rather than the provider, which would let a caller aim a proxy-side request at an address of their choosing.
+ * @default false
+ */
+ enabled: boolean;
+ /**
+ * Max Images
+ * @description How many images from the newest user turn to forward, in wire order. Bounds the added cost of a turn that attaches many images. Images on earlier turns are never forwarded.
+ * @default 1
+ */
+ max_images: number;
};
/**
* CloudZeroExportRequest
@@ -35763,6 +35808,12 @@ export interface components {
* @description Most recent attempt error; detail endpoint only
*/
last_error?: string | null;
+ /**
+ * Models
+ * @description Model groups the sampled traffic is narrowed to; empty means every model the targets use
+ * @default []
+ */
+ models: string[];
/** @description Stratified verdicts; detail endpoint only */
results?: components["schemas"]["ShadowEvalResult"] | null;
/**
@@ -36186,6 +36237,12 @@ export interface components {
* @default 10
*/
max_budget: number;
+ /**
+ * Models
+ * @description Model groups to narrow the sampled traffic to, matched on the group the caller requested and resolved through model_group_alias, so an alias and its target are one name. Empty samples every model the targets use. This ANDs with the targets: a job over a user and one model samples that user's requests on that model across every key they own, and none of their other traffic. Forward jobs only: a reverse job samples exactly the traffic its own router served, which no other model group can name
+ * @default []
+ */
+ models: string[];
/**
* Router Name
* @description The auto-router under evaluation, in either direction: the single-router spelling of router_names. Provide exactly one of the two fields