Merge pull request #35009 from BerriAI/litellm_routing_nav_autorouter

feat(ui): give auto-routers their own tab on Models + Endpoints
This commit is contained in:
tin-berri 2026-07-30 11:54:18 -07:00 • committed by GitHub
commit 708d010115
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2621 additions and 930 deletions

View file

@ -11827,6 +11827,22 @@ def _sort_models(
return all_models
def _is_auto_router_model(model: Mapping[str, object]) -> bool:
"""
True for any auto-router deployment, i.e. every `auto_router/*` strategy
(semantic, complexity, adaptive, quality).
Router._is_auto_router_deployment is deliberately narrower; it answers "is this the
*semantic* auto-router strategy" and returns False for the complexity and adaptive
prefixes, so it is not reusable here.
"""
litellm_params = model.get("litellm_params")
if not isinstance(litellm_params, Mapping):
return False
litellm_model = litellm_params.get("model")
return isinstance(litellm_model, str) and litellm_model.startswith("auto_router/")
def _paginate_models_response(
all_models: List[Dict[str, Any]],
page: int,
@ -12121,6 +12137,15 @@ async def model_info_v2(
"asc",
description="Sort order. Options: asc, desc",
),
exclude_auto_routers: bool | None = fastapi.Query(
False,
description=(
"Omit auto-router deployments (litellm model prefixed `auto_router/`). "
"They select among deployments rather than being deployments themselves, so a "
"caller rendering a deployment list can leave them out. Defaults to false, so "
"existing callers are unaffected"
),
),
):
"""
Paginated model metadata for proxy deployments (pricing, provider, team access).
@ -12288,6 +12313,11 @@ async def model_info_v2(
user_api_key_dict=user_api_key_dict,
)
# `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a
# truthy sentinel object rather than False.
if exclude_auto_routers is True:
all_models = [m for m in all_models if not _is_auto_router_model(m)]
# Update total count to include agents
search_total_count = len(all_models)

View file

@ -292,3 +292,149 @@ def test_model_group_info_invalid_method(client, auth_as, null_router):
response = client.post("/model_group/info", json={})
assert response.status_code == 405
assert len(response.content) > 0
# ---------------------------------------------------------------------------
# GET /v2/model/info?exclude_auto_routers
# ---------------------------------------------------------------------------
@pytest.fixture
def mixed_auto_router_router(monkeypatch):
"""Router carrying one ordinary deployment per auto-router strategy plus two plain ones."""
model_list = [
{
"model_name": "gpt-4o-mini",
"litellm_params": {"model": "openai/gpt-4o-mini"},
"model_info": {"id": "plain-1", "db_model": False},
},
{
"model_name": "tri-tier-router",
"litellm_params": {"model": "auto_router/complexity_router"},
"model_info": {"id": "auto-complexity", "db_model": True},
},
{
"model_name": "support-router",
"litellm_params": {"model": "auto_router/support-router"},
"model_info": {"id": "auto-semantic", "db_model": True},
},
{
"model_name": "adaptive-router",
"litellm_params": {"model": "auto_router/adaptive_router"},
"model_info": {"id": "auto-adaptive", "db_model": True},
},
{
"model_name": "claude-opus",
"litellm_params": {"model": "anthropic/claude-opus-4-6"},
"model_info": {"id": "plain-2", "db_model": False},
},
]
from unittest.mock import AsyncMock
router = MagicMock()
router.model_list = model_list
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "llm_model_list", model_list)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server, "user_model", None)
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
monkeypatch.setattr(
proxy_server,
"_apply_search_filter_to_models",
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
)
monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
yield router
def _model_names(payload) -> list:
return [m["model_name"] for m in payload["data"]]
def test_v2_model_info_includes_auto_routers_by_default(client, auth_as, mixed_auto_router_router):
"""The new param is opt-in; omitting it must not change what any existing caller sees."""
with auth_as():
response = client.get("/v2/model/info")
assert response.status_code == 200
payload = response.json()
assert "tri-tier-router" in _model_names(payload)
assert payload["total_count"] == 5
def test_v2_model_info_excludes_every_auto_router_strategy(client, auth_as, mixed_auto_router_router):
"""All four `auto_router/*` strategies go, not just the semantic one that
Router._is_auto_router_deployment recognises."""
with auth_as():
response = client.get("/v2/model/info", params={"exclude_auto_routers": "true"})
assert response.status_code == 200
payload = response.json()
assert _model_names(payload) == ["gpt-4o-mini", "claude-opus"]
def test_v2_model_info_exclude_auto_routers_shrinks_total_count(client, auth_as, mixed_auto_router_router):
"""The filter must run before the count, or the table pages off a total that
includes rows it never renders (49 shown, 50 claimed)."""
with auth_as():
response = client.get("/v2/model/info", params={"exclude_auto_routers": "true"})
payload = response.json()
assert payload["total_count"] == 2
assert len(payload["data"]) == payload["total_count"]
def test_v2_model_info_exclude_auto_routers_paginates_over_the_filtered_set(
client, auth_as, mixed_auto_router_router
):
"""Page size applies to the filtered list, so no page silently comes back short."""
with auth_as():
response = client.get(
"/v2/model/info", params={"exclude_auto_routers": "true", "page": 1, "size": 1}
)
payload = response.json()
assert payload["total_count"] == 2
assert payload["total_pages"] == 2
assert len(payload["data"]) == 1
@pytest.mark.asyncio
async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_auto_router_router):
"""Called directly (not through FastAPI) the default arrives as a truthy Query object.
Guarding on `is True` is what stops every direct-call test from silently filtering."""
from unittest.mock import AsyncMock
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={}))
monkeypatch.setattr(
proxy_server,
"_apply_search_filter_to_models",
AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))),
)
monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model)
import litellm.proxy.agent_endpoints.model_list_helpers as mlh
monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models))
admin = UserAPIKeyAuth(user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN)
# Deliberately omit exclude_auto_routers, exactly as the pre-existing direct-call tests do.
resp = await proxy_server.model_info_v2(
user_api_key_dict=admin,
model=None,
user_models_only=False,
include_team_models=False,
debug=False,
page=1,
size=50,
search=None,
modelId=None,
teamId=None,
sortBy=None,
sortOrder="asc",
)
assert "tri-tier-router" in [m["model_name"] for m in resp["data"]]

View file

@ -151,14 +151,11 @@
"no-restricted-imports": {
"count": 1
},
"prefer-const": {
"count": 1
},
"react-hooks/purity": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_health.tsx": {
@ -210,11 +207,6 @@
"count": 1
}
},
"src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx": {
"no-restricted-imports": {
"count": 1
@ -1696,7 +1688,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 3
"count": 2
},
"prefer-const": {
"count": 2
@ -2550,17 +2542,12 @@
"count": 1
}
},
"src/components/add_model/add_auto_router_tab.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/add_model/add_auto_router_tab.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 3
"count": 2
}
},
"src/components/add_model/add_model_modes.tsx": {
@ -2568,19 +2555,6 @@
"count": 1
}
},
"src/components/add_model/add_model_tab.test.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/components/add_model/add_model_tab.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 4
}
},
"src/components/add_model/advanced_settings.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3343,7 +3317,7 @@
"count": 5
},
"no-restricted-syntax": {
"count": 153
"count": 152
},
"prefer-const": {
"count": 32

View file

@ -1,28 +0,0 @@
"use client";
import React from "react";
import { Form } from "antd";
import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
interface AutorouterTabProps {
accessToken: string | null;
userId: string | null;
userRole: string;
}
const AutorouterTab: React.FC<AutorouterTabProps> = ({ accessToken, userRole }) => {
const [form] = Form.useForm();
if (!accessToken) {
return null;
}
return (
<div className="w-full">
<AddAutoRouterTab form={form} handleOk={() => form.resetFields()} accessToken={accessToken} userRole={userRole} />
</div>
);
};
export default AutorouterTab;

View file

@ -27,7 +27,6 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () =>
}));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div /> }));
vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () => <div /> }));
import CostOptimizationView from "./CostOptimizationView";

View file

@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () => <div data-testid="autorouter-tab" /> }));
vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () => <div data-testid="caching-tab" /> }));
import CostOptimizationView from "./CostOptimizationView";
@ -11,13 +10,13 @@ import CostOptimizationView from "./CostOptimizationView";
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
describe("CostOptimizationView", () => {
it("renders all four cost-optimization tabs", () => {
const { getByText } = renderView();
it("renders the three cost-optimization tabs and no autorouter tab", () => {
const { getByText, queryByText } = renderView();
expect(getByText("Usage")).toBeInTheDocument();
expect(getByText("Prompt Compression")).toBeInTheDocument();
expect(getByText("Autorouter")).toBeInTheDocument();
expect(getByText("Prompt Caching")).toBeInTheDocument();
expect(queryByText("Autorouter")).not.toBeInTheDocument();
});
it("defaults to the Usage tab and switches the active tab on click", () => {

View file

@ -6,7 +6,6 @@ import { Alert, Tabs } from "antd";
import UsageTab from "./UsageTab";
import PromptCompressionTab from "./PromptCompressionTab";
import AutorouterTab from "./AutorouterTab";
import PromptCachingTab from "./PromptCachingTab";
import { useDailyActivityRange } from "./useDailyActivityRange";
@ -30,11 +29,6 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
label: "Prompt Compression",
children: <PromptCompressionTab accessToken={accessToken} />,
},
{
key: "autorouter",
label: "Autorouter",
children: <AutorouterTab accessToken={accessToken} userId={userId} userRole={userRole} />,
},
{
key: "caching",
label: "Prompt Caching",
@ -50,7 +44,8 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
<h1 className="text-xl font-semibold text-foreground">Cost Optimization</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Track and configure the mechanisms that save you money: prompt compression, prompt caching, and auto routing
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
live under Models + Endpoints, on the Auto-Routers tab
</p>
</div>

View file

@ -7,6 +7,7 @@ import {
selectAutoRouterModelGroups,
useAllProxyModels,
useAutoRouterModelGroups,
useAutoRouters,
useInfiniteModelInfo,
useModelHub,
useModelsInfo,
@ -113,6 +114,9 @@ describe("useModelsInfo", () => {
undefined,
undefined,
undefined,
// exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so
// every other consumer of this hook keeps seeing auto-routers.
false,
);
expect(modelInfoCall).toHaveBeenCalledTimes(1);
});
@ -137,6 +141,9 @@ describe("useModelsInfo", () => {
undefined,
undefined,
undefined,
// exclude_auto_routers defaults off: only the Models + Endpoints table opts in, so
// every other consumer of this hook keeps seeing auto-routers.
false,
);
});
@ -1079,4 +1086,24 @@ describe("useAutoRouterModelGroups", () => {
await waitFor(() => expect(modelInfoCall).toHaveBeenCalled());
expect(result.current.size).toBe(0);
});
// The Auto-Routers tab and the models table read the same /v2/model/info data. Six call
// sites across the app invalidate ["models","list"] after a write; if the auto-router query
// sits in its own namespace, an edit through ModelInfoView leaves the tab stale until a full
// reload, and every future writer has to remember a second key.
describe("auto-router cache namespace", () => {
it("keys the auto-router list under models/list so existing invalidations reach it", async () => {
(modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse);
const { result } = renderHook(() => useAutoRouters(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const keys = queryClient
.getQueryCache()
.findAll({ queryKey: ["models", "list"] })
.map((query) => query.queryKey);
expect(keys.some((key) => JSON.stringify(key).includes("autoRouters"))).toBe(true);
});
});
});

View file

@ -1,4 +1,4 @@
import { useQuery, useInfiniteQuery, UseQueryResult } from "@tanstack/react-query";
import { useQuery, useInfiniteQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "../useAuthorized";
@ -24,7 +24,6 @@ export interface PaginatedModelInfoResponse {
const modelKeys = createQueryKeys("models");
const modelHubKeys = createQueryKeys("modelHub");
const autoRouterKeys = createQueryKeys("autoRouterModelGroups");
const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels");
@ -38,6 +37,7 @@ export const useModelsInfo = (
teamId?: string,
sortBy?: string,
sortOrder?: string,
excludeAutoRouters: boolean = false,
) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<PaginatedModelInfoResponse>({
@ -52,10 +52,25 @@ export const useModelsInfo = (
...(teamId && { teamId }),
...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }),
// Part of the key: callers that exclude auto-routers must not share a cache entry
// with callers that keep them.
...(excludeAutoRouters && { excludeAutoRouters: "true" }),
},
}),
queryFn: async () =>
await modelInfoCall(accessToken!, userId!, userRole!, page, size, search, modelId, teamId, sortBy, sortOrder),
await modelInfoCall(
accessToken!,
userId!,
userRole!,
page,
size,
search,
modelId,
teamId,
sortBy,
sortOrder,
excludeAutoRouters,
),
enabled: Boolean(accessToken && userId && userRole),
});
};
@ -69,6 +84,30 @@ export interface AutoRouterCandidateDeployment {
litellm_params?: { model?: string | null } | null;
}
export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
litellm_params?: {
model?: string | null;
complexity_router_config?: unknown;
complexity_router_default_model?: string | null;
auto_router_config?: unknown;
auto_router_default_model?: string | null;
auto_router_embedding_model?: string | null;
adaptive_router_config?: unknown;
adaptive_router_default_model?: string | null;
quality_router_config?: unknown;
quality_router_default_model?: string | null;
} | null;
model_info?: {
id?: string | null;
/** False for config.yaml-defined deployments, which the update and delete routes refuse. */
db_model?: boolean | null;
created_at?: string | null;
updated_at?: string | null;
team_id?: string | null;
created_by?: string | null;
} | null;
}
export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean =>
Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX));
@ -80,11 +119,14 @@ export const selectAutoRouterModelGroups = (deployments: AutoRouterCandidateDepl
.filter((modelName): modelName is string => Boolean(modelName)),
);
export const selectAutoRouterDeployments = (deployments: AutoRouterDeployment[]): AutoRouterDeployment[] =>
deployments.filter(isAutoRouterDeployment);
const fetchAllModelDeployments = async (
accessToken: string,
userId: string,
userRole: string,
): Promise<AutoRouterCandidateDeployment[]> => {
): Promise<AutoRouterDeployment[]> => {
const firstPage: PaginatedModelInfoResponse = await modelInfoCall(
accessToken,
userId,
@ -100,18 +142,28 @@ const fetchAllModelDeployments = async (
);
return [firstPage, ...remainingPages].flatMap(
(page: PaginatedModelInfoResponse) => page?.data ?? [],
) as AutoRouterCandidateDeployment[];
) as AutoRouterDeployment[];
};
/**
* Deliberately under the same `models/list` namespace as useModelsInfo: it is the same
* /v2/model/info data, and every writer in the app already invalidates ["models","list"].
* A private namespace meant an edit through ModelInfoView left this list stale, and every
* future writer would have had to remember a second key.
*/
const autoRouterListKey = (userId: string | null, userRole: string | null) =>
modelKeys.list({
filters: {
scope: "autoRouters",
...(userId && { userId }),
...(userRole && { userRole }),
},
});
export const useAutoRouterModelGroups = (): ReadonlySet<string> => {
const { accessToken, userId, userRole } = useAuthorized();
const { data } = useQuery<AutoRouterCandidateDeployment[], Error, ReadonlySet<string>>({
queryKey: autoRouterKeys.list({
filters: {
...(userId && { userId }),
...(userRole && { userRole }),
},
}),
const { data } = useQuery<AutoRouterDeployment[], Error, ReadonlySet<string>>({
queryKey: autoRouterListKey(userId, userRole),
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
enabled: Boolean(accessToken && userId && userRole),
select: selectAutoRouterModelGroups,
@ -119,6 +171,23 @@ export const useAutoRouterModelGroups = (): ReadonlySet<string> => {
return data ?? NO_AUTO_ROUTERS;
};
export const useAutoRouters = (): UseQueryResult<AutoRouterDeployment[], Error> => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<AutoRouterDeployment[], Error, AutoRouterDeployment[]>({
queryKey: autoRouterListKey(userId, userRole),
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
enabled: Boolean(accessToken && userId && userRole),
select: selectAutoRouterDeployments,
});
};
export const useInvalidateAutoRouters = (): (() => Promise<void>) => {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
};
};
export const useModelHub = () => {
const { accessToken } = useAuthorized();
return useQuery({

View file

@ -104,6 +104,9 @@ const AllModelsTab = ({
teamIdForQuery,
sortBy,
sortOrder,
// Auto-routers are routing constructs, not deployments; the sibling Auto-Routers tab
// lists and manages them. Excluded server-side so total_count stays honest.
true,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;

View file

@ -0,0 +1,260 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils";
import { AutoRoutersPanel } from "./AutoRoutersPanel";
const { modelInfoCall, modelDeleteCall } = vi.hoisted(() => ({
modelInfoCall: vi.fn(),
modelDeleteCall: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/components/networking", () => ({
modelInfoCall,
modelDeleteCall,
modelHubCall: vi.fn(),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
}));
const { openModel } = vi.hoisted(() => ({ openModel: vi.fn() }));
vi.mock("@/app/(dashboard)/models-and-endpoints/detailNavigation", () => ({
useModelDetailRouting: () => ({ openModel, modelId: null, teamId: null, openTeam: vi.fn(), close: vi.fn() }),
}));
vi.mock("@/components/edit_auto_router/edit_auto_router_modal", () => ({
__esModule: true,
default: ({ modelData }: { modelData: { model_name?: string; model_info?: { id?: string } } }) => (
<div data-testid="edit-auto-router-modal">
edit:{modelData.model_name}:{modelData.model_info?.id}
</div>
),
}));
vi.mock("@/components/add_model/add_auto_router_tab", () => ({
__esModule: true,
default: ({ handleOk }: { handleOk: () => void }) => (
<button type="button" onClick={handleOk}>
Submit auto router
</button>
),
}));
// A realistic /v2/model/info page: two auto-routers among ordinary deployments. The panel must
// render exactly the auto_router/* rows; a view that renders page.data unfiltered passes a
// "renders a table" assertion but fails this one.
const DEPLOYMENTS = [
{
// DB-created adaptive router: no editor for its shape, but it must stay deletable, since
// auto-routers are excluded from Models + Endpoints and this tab is the only delete path.
model_name: "adaptive-router",
litellm_params: { model: "auto_router/adaptive_router" },
model_info: { id: "auto-3", db_model: true },
},
{
// config.yaml row: the API refuses both update and delete, so neither control may appear.
model_name: "config-router",
litellm_params: {
model: "auto_router/complexity_router",
complexity_router_config: { tiers: {}, classifier_type: "llm" },
},
model_info: { id: "auto-4", db_model: false },
},
{
model_name: "gpt-4o-mini",
litellm_params: { model: "openai/gpt-4o-mini" },
model_info: { id: "plain-1" },
},
{
model_name: "tri-tier-router",
litellm_params: {
model: "auto_router/complexity_router",
complexity_router_config: { tiers: { SIMPLE: ["gpt-4o-mini"] }, classifier_type: "heuristic" },
complexity_router_default_model: "gpt-4o-mini",
},
model_info: { id: "auto-1", db_model: true, created_at: "2026-07-28T21:40:09.900000+00:00" },
},
{
model_name: "anthropic-opus-4-6",
litellm_params: { model: "anthropic/claude-opus-4-6" },
model_info: { id: "plain-2" },
},
{
model_name: "support-router",
litellm_params: {
model: "auto_router/support-router",
auto_router_config: JSON.stringify({ routes: [{ name: "gpt-4o-mini" }] }),
auto_router_default_model: "gpt-4o-mini",
},
model_info: { id: "auto-2", db_model: true, created_at: "2026-07-27T10:00:00.000000+00:00" },
},
];
const pageOf = (data: typeof DEPLOYMENTS) => ({
data,
total_count: data.length,
current_page: 1,
total_pages: 1,
size: 1000,
});
const mockDeploymentsPage = () => {
modelInfoCall.mockResolvedValue(pageOf(DEPLOYMENTS));
};
const renderPanel = (canModify = true) =>
renderWithProviders(
<AutoRoutersPanel
accessToken="token"
userRole="Admin"
userID="u-admin"
teams={null}
createScope={canModify ? "unscoped-ok" : "forbidden"}
/>,
);
describe("AutoRoutersPanel", () => {
beforeEach(() => {
// The shared test client caches with staleTime: Infinity and refetchOnMount: false, so
// without this every test after the first reads the previous test's deployment page.
testQueryClient.clear();
modelInfoCall.mockReset();
modelDeleteCall.mockClear();
openModel.mockClear();
mockDeploymentsPage();
});
it("lists only auto_router deployments, not every model on the proxy", async () => {
renderPanel();
expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
expect(await screen.findByText("support-router")).toBeInTheDocument();
expect(screen.queryByText("gpt-4o-mini", { selector: "span.text-sm.font-medium" })).not.toBeInTheDocument();
expect(screen.queryByText("anthropic-opus-4-6", { selector: "span.text-sm.font-medium" })).not.toBeInTheDocument();
});
it("labels Type by classifier rather than by router family", async () => {
renderPanel();
expect(await screen.findByText("Heuristic")).toBeInTheDocument();
expect(await screen.findByText("Semantic")).toBeInTheDocument();
});
// Reuses the models-page drill-in, so an auto router opens the full ModelInfoView with
// Model Settings and Edit Settings, not a parallel detail view that reimplements part of it.
it("opens the shared model detail view on row click", async () => {
const user = userEvent.setup();
renderPanel();
await user.click(await screen.findByRole("button", { name: "support-router" }));
expect(openModel).toHaveBeenCalledWith("auto-2");
});
it("opens the create form in a dialog and refetches the list after a create", async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText("tri-tier-router");
const callsBeforeCreate = modelInfoCall.mock.calls.length;
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
// A dialog, not a full-panel swap: the list stays mounted behind it.
const dialog = await screen.findByRole("dialog");
expect(dialog).toHaveTextContent("Add Auto Router");
expect(screen.getByText("tri-tier-router")).toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Submit auto router" }));
// Back on the list, and the deployment query was invalidated so a new router shows up
// without a manual page reload.
expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
await waitFor(() => expect(modelInfoCall.mock.calls.length).toBeGreaterThan(callsBeforeCreate));
});
// The page decides who may write (proxy admin or team admin); the panel just has to make
// every write affordance absent when told no, rather than let a submit 403 later. Reading
// stays open: a read-only caller can still drill into the detail view.
it("shows the list but no write affordances when canModify is false", async () => {
renderPanel(false);
expect(await screen.findByText("tri-tier-router")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add Auto Router" })).not.toBeInTheDocument();
expect(screen.queryByTestId("auto-router-actions-auto-1")).not.toBeInTheDocument();
// Still navigable, because opening the detail view is a read.
expect(screen.getByRole("button", { name: "tri-tier-router" })).toBeInTheDocument();
});
// Auto-routers are hidden from Models + Endpoints, which used to be the only route to the
// delete action, so this tab is now the only place an auto router can be removed.
it("deletes the chosen router by its model id and refetches", async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText("support-router");
const callsBeforeDelete = modelInfoCall.mock.calls.length;
await user.click(screen.getByTestId("auto-router-actions-auto-2"));
await user.click(await screen.findByTestId("auto-router-action-delete"));
await user.click(await screen.findByRole("button", { name: /^delete$/i }));
await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "auto-2"));
await waitFor(() => expect(modelInfoCall.mock.calls.length).toBeGreaterThan(callsBeforeDelete));
});
it("does not delete when the confirmation is dismissed", async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText("support-router");
await user.click(screen.getByTestId("auto-router-actions-auto-2"));
await user.click(await screen.findByTestId("auto-router-action-delete"));
await user.click(await screen.findByRole("button", { name: /cancel/i }));
expect(modelDeleteCall).not.toHaveBeenCalled();
});
it("gives a read-only caller no delete affordance", async () => {
renderPanel(false);
await screen.findByText("support-router");
expect(screen.queryByTestId("auto-router-actions-auto-2")).not.toBeInTheDocument();
});
it("renders an empty state when the proxy has models but no auto routers", async () => {
modelInfoCall.mockResolvedValue(
pageOf(DEPLOYMENTS.filter((d) => !d.litellm_params.model.startsWith("auto_router/"))),
);
renderPanel();
expect(await screen.findByText("No auto routers yet")).toBeInTheDocument();
});
it("keeps delete available on a DB-created adaptive router that has no editor", async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText("adaptive-router");
await user.click(screen.getByTestId("auto-router-actions-auto-3"));
await user.click(await screen.findByTestId("auto-router-action-delete"));
await user.click(await screen.findByRole("button", { name: /^delete$/i }));
await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("token", "auto-3"));
});
it("offers no delete on a config-defined router, which the API would refuse", async () => {
renderPanel();
await screen.findByText("config-router");
expect(screen.queryByTestId("auto-router-actions-auto-4")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,130 @@
"use client";
import { Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { useAutoRouters, useInvalidateAutoRouters } from "@/app/(dashboard)/hooks/models/useModels";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
import AddAutoRouterTab from "@/components/add_model/add_auto_router_tab";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { modelDeleteCall } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import { Team } from "@/components/networking";
import { AutoRoutersTable } from "./AutoRoutersTable";
import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
interface AutoRoutersPanelProps {
accessToken: string;
userRole: string;
userID: string | null;
teams: Team[] | null;
/** Owned by the page, which knows how this caller must scope what they create. */
createScope: ModelWriteScope;
}
export function AutoRoutersPanel({ accessToken, userRole, userID, teams, createScope }: AutoRoutersPanelProps) {
const canCreate = createScope !== "forbidden";
const { data: deployments, isLoading } = useAutoRouters();
const invalidateAutoRouters = useInvalidateAutoRouters();
// Clicking a router opens the same ?model= drill-in the All Models table uses, so an auto
// router gets the full ModelInfoView: Model Settings, Edit Settings, Edit Auto Router and
// Delete. A separate detail view here would be a worse copy of it.
const { openModel } = useModelDetailRouting();
const [isCreating, setIsCreating] = useState(false);
const [deletingRouter, setDeletingRouter] = useState<AutoRouterRow | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const routers = useMemo(
() => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams),
[deployments, userRole, userID, teams],
);
const handleCreated = () => {
setIsCreating(false);
void invalidateAutoRouters();
};
const handleConfirmDelete = async () => {
if (!deletingRouter) return;
setIsDeleting(true);
try {
await modelDeleteCall(accessToken, deletingRouter.id);
NotificationsManager.success(`Deleted auto router: ${deletingRouter.name}`);
setDeletingRouter(null);
await invalidateAutoRouters();
} catch (error) {
NotificationsManager.fromBackend(`Failed to delete auto router: ${error}`);
} finally {
setIsDeleting(false);
}
};
return (
<div className="w-full space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-base font-semibold text-foreground">Auto routers</h2>
<p className="mt-1 text-sm text-muted-foreground">
Auto routers sit above your deployments and pick a model per request. They are called like any other model,
so clients keep using a single model name.
</p>
</div>
{canCreate && (
<Button onClick={() => setIsCreating(true)} className="shrink-0">
<Plus />
Add Auto Router
</Button>
)}
</div>
<AutoRoutersTable
routers={routers}
isLoading={isLoading}
canModify={canCreate}
onRouterClick={(row) => openModel(row.id)}
onDeleteClick={setDeletingRouter}
/>
<Dialog open={isCreating} onOpenChange={setIsCreating}>
{/* The form is long, so the dialog caps its height and scrolls its body rather than
growing past the viewport. */}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Add Auto Router</DialogTitle>
<DialogDescription>
Routes each request to a model by classifying its complexity. Called like any other model, so clients keep
using a single model name.
</DialogDescription>
</DialogHeader>
<AddAutoRouterTab
handleOk={handleCreated}
accessToken={accessToken}
userRole={userRole}
createScope={createScope}
/>
</DialogContent>
</Dialog>
{deletingRouter && (
<DeleteResourceModal
isOpen
title="Delete Auto Router"
message={`Are you sure you want to delete "${deletingRouter.name}"? Any client still calling this model name will start failing.`}
resourceInformationTitle="Auto router"
resourceInformation={[
{ label: "Name", value: deletingRouter.name },
{ label: "Type", value: deletingRouter.typeLabel },
{ label: "ID", value: deletingRouter.id },
]}
onCancel={() => setDeletingRouter(null)}
onOk={handleConfirmDelete}
confirmLoading={isDeleting}
/>
)}
</div>
);
}

View file

@ -0,0 +1,68 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { AutoRouterIcon } from "@/components/shared/table_cells";
import { getAutoRoutersTableColumns } from "./AutoRoutersTableColumns";
import { AutoRouterRow } from "./autoRouterRows";
interface AutoRoutersTableProps {
routers: AutoRouterRow[];
isLoading: boolean;
canModify: boolean;
onRouterClick: (row: AutoRouterRow) => void;
onDeleteClick: (row: AutoRouterRow) => void;
}
const PAGE_SIZE_OPTIONS = [10, 25, 50];
function EmptyState({ canModify }: { canModify: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<AutoRouterIcon size={20} className="text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No auto routers yet</div>
<div className="text-sm text-muted-foreground">
{canModify
? "Create an auto router to pick the right model per request instead of pinning one."
: "An auto router picks the right model per request instead of pinning one."}
</div>
</div>
);
}
export function AutoRoutersTable({
routers,
isLoading,
canModify,
onRouterClick,
onDeleteClick,
}: AutoRoutersTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const columns = useMemo(
() => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }),
[canModify, onRouterClick, onDeleteClick],
);
return (
<DataTable
data={routers}
columns={columns}
getRowId={(router) => router.id}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
paginationMode="client"
pageSizeOptions={PAGE_SIZE_OPTIONS}
isLoading={isLoading}
loadingMessage="Loading auto routers…"
noDataMessage={<EmptyState canModify={canModify} />}
size="compact"
/>
);
}

View file

@ -0,0 +1,173 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { useEffect, useMemo, useRef, useState } from "react";
import { MoreHorizontal, Trash2 } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { AutoRouterRow } from "./autoRouterRows";
import { fitPills } from "./fitPills";
function TypeCell({ row }: { row: AutoRouterRow }) {
return (
<Badge variant="secondary" className="font-normal">
{row.typeLabel}
</Badge>
);
}
function TargetsCell({ targets }: { targets: string[] }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const node = containerRef.current;
if (!node || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const measured = entries[0]?.contentRect.width;
if (typeof measured === "number") setWidth(measured);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
const { visible, overflow } = useMemo(() => fitPills(targets, width), [targets, width]);
if (targets.length === 0) {
return <span className="text-sm text-muted-foreground">-</span>;
}
return (
<div ref={containerRef} className="flex w-full min-w-0 flex-nowrap items-center gap-1 overflow-hidden">
{visible.map((target) => (
<Badge key={target} variant="secondary" className="max-w-full shrink truncate font-normal">
{target}
</Badge>
))}
{overflow > 0 && (
<span className="shrink-0 text-xs text-muted-foreground" title={targets.join(", ")}>
+{overflow}
</span>
)}
</div>
);
}
function AutoRouterRowActions({
row,
onDeleteClick,
}: {
row: AutoRouterRow;
onDeleteClick: (row: AutoRouterRow) => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Open actions for ${row.name}`}
data-testid={`auto-router-actions-${row.id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
variant="destructive"
data-testid="auto-router-action-delete"
onClick={() => onDeleteClick(row)}
>
<Trash2 />
Delete auto router
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface AutoRoutersTableColumnsDeps {
canModify: boolean;
onRouterClick: (row: AutoRouterRow) => void;
onDeleteClick: (row: AutoRouterRow) => void;
}
export const getAutoRoutersTableColumns = ({
canModify,
onRouterClick,
onDeleteClick,
}: AutoRoutersTableColumnsDeps): ColumnDef<AutoRouterRow>[] => [
{
id: "name",
accessorKey: "name",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
size: 260,
enableSorting: true,
cell: ({ row }) => <IdentityCell title={row.original.name || "-"} onClick={() => onRouterClick(row.original)} />,
},
{
id: "kind",
accessorKey: "kind",
meta: { title: "Type" },
header: "Type",
size: 180,
enableSorting: false,
cell: ({ row }) => <TypeCell row={row.original} />,
},
{
id: "targets",
meta: { title: "Routes to" },
header: "Routes to",
size: 320,
enableSorting: false,
cell: ({ row }) => <TargetsCell targets={row.original.targets} />,
},
{
id: "defaultModel",
accessorKey: "defaultModel",
meta: { title: "Default model" },
header: "Default model",
size: 200,
enableSorting: false,
cell: ({ row }) =>
row.original.defaultModel ? (
<Badge variant="secondary" className="max-w-full truncate font-normal" title={row.original.defaultModel}>
{row.original.defaultModel}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
),
},
{
id: "createdAt",
accessorKey: "createdAt",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 150,
enableSorting: true,
sortingFn: "datetime",
cell: ({ row }) => <DateCell value={row.original.createdAt} precision="date" />,
},
...(canModify
? [
{
id: "actions",
meta: { title: "" },
header: "",
size: 60,
enableSorting: false,
cell: ({ row }) =>
row.original.canDelete ? <AutoRouterRowActions row={row.original} onDeleteClick={onDeleteClick} /> : null,
} satisfies ColumnDef<AutoRouterRow>,
]
: []),
];

View file

@ -0,0 +1,262 @@
import { describe, expect, it } from "vitest";
import { autoRouterStrategy, isComplexityRouter } from "@/components/add_model/auto_router_strategies";
import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
// Existing cases assert resource classification, so they run as a proxy admin: the actor
// gate is then a pass-through and canEdit/canDelete still reflect the row itself.
const ADMIN = { userRole: "Admin", userID: "u-admin" };
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
const complexityDeployment = {
model_name: "tri-tier-router",
litellm_params: {
model: "auto_router/complexity_router",
complexity_router_config: {
tiers: {
SIMPLE: ["gpt-4o-mini"],
MEDIUM: ["anthropic-sonnet-4-6"],
COMPLEX: ["anthropic-opus-4-6", "gpt-4o-mini"],
REASONING: [],
},
classifier_type: "heuristic",
},
complexity_router_default_model: "gpt-4o-mini",
},
model_info: { id: "cid-1", db_model: true, created_at: "2026-07-28T21:40:09.900000+00:00" },
};
const semanticDeployment = {
model_name: "support-router",
litellm_params: {
model: "auto_router/support-router",
auto_router_config: JSON.stringify({
routes: [
{ name: "gpt-4o-mini", utterances: ["reset my password"] },
{ name: "anthropic-opus-4-6", utterances: ["design a distributed system"] },
],
}),
auto_router_default_model: "gpt-4o-mini",
},
model_info: { id: "sid-1", db_model: true, created_at: "2026-07-27T10:00:00.000000+00:00" },
};
describe("autoRouterRows", () => {
it("classifies a complexity router and unions its tier models as targets", () => {
const row = toAutoRouterRow(complexityDeployment, 0, ADMIN, null);
expect(row.kind).toBe("complexity");
expect(row.typeLabel).toBe("Heuristic");
// Union across tiers, de-duplicated: gpt-4o-mini appears in both SIMPLE and COMPLEX.
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6", "anthropic-opus-4-6"]);
expect(row.defaultModel).toBe("gpt-4o-mini");
expect(row.id).toBe("cid-1");
});
it("parses a semantic router whose config arrives as a JSON string", () => {
const row = toAutoRouterRow(semanticDeployment, 0, ADMIN, null);
expect(row.kind).toBe("semantic");
expect(row.typeLabel).toBe("Semantic");
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-opus-4-6"]);
expect(row.defaultModel).toBe("gpt-4o-mini");
});
it("shows a tier pinned as a bare string, which the backend accepts as `str | list[str]`", () => {
const row = toAutoRouterRow(
{
...complexityDeployment,
litellm_params: {
...complexityDeployment.litellm_params,
complexity_router_config: {
tiers: { SIMPLE: "gpt-4o-mini", MEDIUM: ["anthropic-sonnet-4-6"], COMPLEX: "", REASONING: [] },
classifier_type: "heuristic",
},
},
},
0,
ADMIN,
null,
);
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6"]);
});
it("labels a router using the LLM classifier", () => {
const row = toAutoRouterRow(
{
...complexityDeployment,
litellm_params: {
...complexityDeployment.litellm_params,
complexity_router_config: { tiers: {}, classifier_type: "llm", adaptive: true },
},
},
0,
ADMIN,
null,
);
expect(row.typeLabel).toBe("LLM Classifier");
});
it("treats a deployment carrying complexity_router_config as complexity even off the canonical model string", () => {
expect(isComplexityRouter({ model: "auto_router/legacy", complexity_router_config: { tiers: {} } })).toBe(true);
});
it("survives an unparseable config instead of throwing", () => {
const row = toAutoRouterRow(
{
model_name: "broken",
litellm_params: { model: "auto_router/broken", auto_router_config: "{not json" },
model_info: { id: "bid-1" },
},
0,
ADMIN,
null,
);
expect(row.kind).toBe("semantic");
expect(row.targets).toEqual([]);
});
it("falls back to a stable synthetic id when the deployment has no model_info id", () => {
const rows = toAutoRouterRows(
[
{ model_name: "a", litellm_params: { model: "auto_router/a" } },
{ model_name: "b", litellm_params: { model: "auto_router/b" } },
],
ADMIN,
null,
);
expect(rows.map((row) => row.id)).toEqual(["a-0", "b-1"]);
});
// Regression: adaptive and quality routers used to fall through to the semantic branch,
// which read the wrong config key and reported an empty route list and a null default.
it("classifies an adaptive router as adaptive, not semantic", () => {
const row = toAutoRouterRow(
{
model_name: "smart-router",
litellm_params: {
model: "auto_router/adaptive_router",
adaptive_router_default_model: "gpt-4o-mini",
adaptive_router_config: { available_models: ["gpt-4o", "gpt-4o-mini"] },
},
model_info: { id: "ad-1" },
},
0,
ADMIN,
null,
);
expect(row.kind).toBe("adaptive");
expect(row.typeLabel).toBe("Adaptive");
expect(row.targets).toEqual(["gpt-4o", "gpt-4o-mini"]);
expect(row.defaultModel).toBe("gpt-4o-mini");
});
it("classifies a quality router as quality, not semantic", () => {
const row = toAutoRouterRow(
{
model_name: "quality-router",
litellm_params: {
model: "auto_router/quality_router",
quality_router_default_model: "gpt-4o",
quality_router_config: { available_models: ["gpt-4o"] },
},
model_info: { id: "q-1" },
},
0,
ADMIN,
null,
);
expect(row.kind).toBe("quality");
expect(row.typeLabel).toBe("Quality");
expect(row.targets).toEqual(["gpt-4o"]);
});
it("mirrors the backend prefix ordering, so a named strategy never reads as semantic", () => {
const kindOf = (model: string) => autoRouterStrategy({ model }).kind;
expect(kindOf("auto_router/complexity_router")).toBe("complexity");
expect(kindOf("auto_router/adaptive_router")).toBe("adaptive");
expect(kindOf("auto_router/quality_router")).toBe("quality");
expect(kindOf("auto_router/my-own-router")).toBe("semantic");
});
// The capability matrix. Origin and strategy constrain DIFFERENT capabilities, and
// collapsing them into one "editable" flag is what stranded DB-created adaptive routers
// with no delete control. Live-verified: for a config row PATCH /model/{id}/update 404s
// and POST /model/delete 400s.
const rowFor = (model: string, dbModel: boolean) =>
toAutoRouterRow(
{ model_name: "r", litellm_params: { model }, model_info: { id: "x", db_model: dbModel } },
0,
ADMIN,
null,
);
it.each([
{ model: "auto_router/complexity_router", db: true, canEdit: true, canDelete: true, reason: null },
{ model: "auto_router/my-semantic", db: true, canEdit: true, canDelete: true, reason: null },
// No editor for its shape, but deleting never reads the config, so delete stays.
{ model: "auto_router/adaptive_router", db: true, canEdit: false, canDelete: true, reason: "no-editor" },
{ model: "auto_router/quality_router", db: true, canEdit: false, canDelete: true, reason: "no-editor" },
// config.yaml rows: the API refuses both, whatever the strategy.
{ model: "auto_router/complexity_router", db: false, canEdit: false, canDelete: false, reason: "config-managed" },
{ model: "auto_router/adaptive_router", db: false, canEdit: false, canDelete: false, reason: "config-managed" },
])("$model (db_model=$db) -> canEdit=$canEdit canDelete=$canDelete", (spec) => {
const row = rowFor(spec.model, spec.db);
expect(row.canEdit).toBe(spec.canEdit);
expect(row.canDelete).toBe(spec.canDelete);
expect(row.editBlockedReason).toBe(spec.reason);
});
it("treats a missing db_model as config-defined rather than assuming it is writable", () => {
const row = toAutoRouterRow({ ...complexityDeployment, model_info: { id: "unknown-1" } }, 0, ADMIN, null);
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
});
describe("autoRouterRows actor gating", () => {
const TEAMS = [
{ team_id: "team-1", members_with_roles: [{ user_id: "u-team-admin", user_email: "t@t", role: "admin" }] },
] as never;
const rowIn = (actor: { userRole: string; userID: string }, teamId: string | null) =>
toAutoRouterRow(
{ ...complexityDeployment, model_info: { id: "cid-1", db_model: true, team_id: teamId } },
0,
actor,
TEAMS,
);
// Opening the tab to team admins puts rows they cannot act on in the same list: other
// teams' routers, and the proxy-level unscoped ones. PATCH and DELETE both 403 those, so
// the affordance has to be per row rather than per tab.
it("hides write affordances on another team's router", () => {
const row = rowIn(TEAM_ADMIN, "other-team");
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
it("hides them on an unscoped router a proxy admin owns", () => {
const row = rowIn(TEAM_ADMIN, null);
expect(row.canEdit).toBe(false);
expect(row.canDelete).toBe(false);
});
// Authorizing on created_by would fail this: the API lets any admin of the owning team act.
it("keeps them on the team's router regardless of who created it", () => {
const row = rowIn(TEAM_ADMIN, "team-1");
expect(row.canEdit).toBe(true);
expect(row.canDelete).toBe(true);
});
it("lets a proxy admin act on any team's router", () => {
const row = rowIn(ADMIN, "other-team");
expect(row.canEdit).toBe(true);
expect(row.canDelete).toBe(true);
});
});

View file

@ -0,0 +1,121 @@
import { AutoRouterDeployment } from "@/app/(dashboard)/hooks/models/useModels";
import {
AutoRouterKind,
EditBlockedReason,
autoRouterCapabilities,
autoRouterStrategy,
} from "@/components/add_model/auto_router_strategies";
import { normalizeTierModels } from "@/components/add_model/complexity_router_tiers";
import { Team } from "@/components/networking";
import { type ModelActor, canModifyModel } from "@/utils/modelPermissions";
export type { AutoRouterKind };
/** Who is looking at the list; decides which rows offer write affordances. */
export type AutoRouterActor = ModelActor;
export interface AutoRouterRow {
id: string;
name: string;
kind: AutoRouterKind;
typeLabel: string;
/** Edit needs an API-created row AND a strategy the dashboard has a form for. */
canEdit: boolean;
/**
* Resource capability ANDed with the caller's standing on this specific row. A team admin
* sees rows they cannot delete (another team's, or one a teammate created), and the API
* would 403 those, so the affordance has to be per row rather than per tab.
*/
canDelete: boolean;
editBlockedReason: EditBlockedReason | null;
targets: string[];
defaultModel: string | null;
createdAt: string | null;
deployment: AutoRouterDeployment;
}
const safeParse = (value: string): unknown => {
try {
return JSON.parse(value);
} catch {
return null;
}
};
const asRecord = (value: unknown): Record<string, unknown> => {
const parsed: unknown = typeof value === "string" ? safeParse(value) : value;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
};
const asStringArray = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
const dedupe = (models: string[]): string[] => Array.from(new Set(models));
export const complexityTypeLabel = (config: Record<string, unknown>): string =>
config.classifier_type === "llm" ? "LLM Classifier" : "Heuristic";
interface Presentation {
typeLabel: string;
targets: string[];
}
// Adaptive and quality both declare a flat pool and have no editor here, so the row reports
// what is configured rather than interpreting it.
const configManaged = (label: string, config: Record<string, unknown>): Presentation => ({
typeLabel: label,
targets: asStringArray(config.available_models),
});
/** How each strategy renders itself, given its own config object. */
const PRESENTERS: Record<AutoRouterKind, (config: Record<string, unknown>) => Presentation> = {
complexity: (config) => ({
typeLabel: complexityTypeLabel(config),
targets: dedupe(Object.values(asRecord(config.tiers)).flatMap(normalizeTierModels)),
}),
semantic: (config) => {
const routes = dedupe(
(Array.isArray(config.routes) ? config.routes : [])
.map((route) => asRecord(route).name)
.filter((name): name is string => typeof name === "string" && name.length > 0),
);
return { typeLabel: "Semantic", targets: routes };
},
adaptive: (config) => configManaged("Adaptive", config),
quality: (config) => configManaged("Quality", config),
};
export const toAutoRouterRow = (
deployment: AutoRouterDeployment,
index: number,
actor: AutoRouterActor,
teams: Team[] | null,
): AutoRouterRow => {
const params = deployment.litellm_params ?? {};
const info = deployment.model_info ?? {};
const name = deployment.model_name ?? "";
const strategy = autoRouterStrategy(params);
const { canEdit, canDelete, editBlockedReason } = autoRouterCapabilities(params, info);
const mayActOnRow = canModifyModel(actor, teams, { teamId: info.team_id, isDbModel: info.db_model === true });
return {
id: info.id ?? `${name}-${index}`,
name,
kind: strategy.kind,
canEdit: canEdit && mayActOnRow,
canDelete: canDelete && mayActOnRow,
editBlockedReason,
createdAt: info.created_at ?? null,
defaultModel: (params[strategy.defaultModelKey] as string | null | undefined) ?? null,
deployment,
...PRESENTERS[strategy.kind](asRecord(params[strategy.configKey])),
};
};
export const toAutoRouterRows = (
deployments: AutoRouterDeployment[],
actor: AutoRouterActor,
teams: Team[] | null,
): AutoRouterRow[] => deployments.map((deployment, index) => toAutoRouterRow(deployment, index, actor, teams));

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { fitPills, pillWidth } from "./fitPills";
const TARGETS = ["anthropic-sonnet-4-6", "gpt-4o-mini", "anthropic-opus-4-6", "voyage-4-large"];
describe("fitPills", () => {
it("keeps everything on one row when it all fits", () => {
const wide = TARGETS.reduce((total, label) => total + pillWidth(label) + 4, 0) + 40;
expect(fitPills(TARGETS, wide)).toEqual({ visible: TARGETS, overflow: 0 });
});
it("shows more pills as the column gets wider", () => {
const narrow = fitPills(TARGETS, 200);
const wider = fitPills(TARGETS, 420);
expect(narrow.visible.length).toBeLessThan(wider.visible.length);
expect(narrow.visible.length + narrow.overflow).toBe(TARGETS.length);
expect(wider.visible.length + wider.overflow).toBe(TARGETS.length);
});
it("reserves room for the +N counter so the row never overflows", () => {
const { visible } = fitPills(TARGETS, 220);
const used = visible.reduce((total, label, index) => total + pillWidth(label) + (index === 0 ? 0 : 4), 0);
// 28px counter + its 4px gap must still fit alongside the visible pills.
expect(used + 32).toBeLessThanOrEqual(220);
});
it("always shows at least one pill, even when a single name is wider than the column", () => {
expect(fitPills(["an-extremely-long-deployment-name-that-never-fits"], 40)).toEqual({
visible: ["an-extremely-long-deployment-name-that-never-fits"],
overflow: 0,
});
});
it("shows one pill before the first measurement rather than flashing every pill", () => {
expect(fitPills(TARGETS, 0)).toEqual({ visible: [TARGETS[0]], overflow: 3 });
});
it("handles an empty target list", () => {
expect(fitPills([], 300)).toEqual({ visible: [], overflow: 0 });
});
});

View file

@ -0,0 +1,52 @@
/**
* How many pills fit on ONE row of a given width, leaving room for a "+N" counter.
*
* jsdom reports no layout and the shared ResizeObserver mock only fires inside chart
* subtrees, so this stays a pure width-in / count-out function: the component measures and
* this decides, which keeps the overflow rule unit-testable.
*/
const CHAR_WIDTH = 6.5;
const PILL_PADDING = 18;
const PILL_GAP = 4;
const OVERFLOW_WIDTH = 28;
export const pillWidth = (label: string): number => label.length * CHAR_WIDTH + PILL_PADDING;
export interface FittedPills {
visible: string[];
overflow: number;
}
export const fitPills = (labels: string[], availableWidth: number): FittedPills => {
if (labels.length === 0) return { visible: [], overflow: 0 };
// Unmeasured (0 or negative) means the first paint before ResizeObserver reports. Show one
// pill rather than all of them, so the row never flashes multi-line and then collapses.
if (availableWidth <= 0) {
return { visible: labels.slice(0, 1), overflow: labels.length - 1 };
}
const fitted: string[] = [];
let used = 0;
for (const [index, label] of labels.entries()) {
const remaining = labels.length - index - 1;
const gap = fitted.length === 0 ? 0 : PILL_GAP;
// Anything still queued after this pill needs room for the "+N" counter beside it.
const reserve = remaining > 0 ? PILL_GAP + OVERFLOW_WIDTH : 0;
if (used + gap + pillWidth(label) + reserve > availableWidth) break;
used += gap + pillWidth(label);
fitted.push(label);
}
// Always show at least one pill; a single over-long name truncates via CSS instead of
// collapsing the cell to a bare "+N".
if (fitted.length === 0) {
return { visible: labels.slice(0, 1), overflow: labels.length - 1 };
}
return { visible: fitted, overflow: labels.length - fitted.length };
};

View file

@ -7,6 +7,7 @@ import ModelsAndEndpointsPage from "./page";
vi.mock("./panels/AllModelsPanel", () => ({ default: () => <div data-testid="panel-all-models" /> }));
vi.mock("./panels/AddModelPanel", () => ({ default: () => <div data-testid="panel-add" /> }));
vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () => <div data-testid="panel-auto-routers" /> }));
vi.mock("./panels/LlmCredentialsPanel", () => ({ default: () => <div data-testid="panel-credentials" /> }));
vi.mock("./panels/PassThroughPanel", () => ({ default: () => <div data-testid="panel-pass-through" /> }));
vi.mock("./panels/HealthStatusPanel", () => ({ default: () => <div data-testid="panel-health" /> }));
@ -97,4 +98,34 @@ describe("ModelsAndEndpointsPage", () => {
expect(queryByRole("tab", { name: "LLM Credentials" })).toBeNull();
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
});
// Auto-routers are excluded from the All Models table, so this tab is their home: the only
// place in the product to list, create, edit or delete one.
describe("Auto-Routers tab", () => {
it("sits third, after All Models and Add Model", () => {
const { getAllByRole } = renderPage();
const tabs = getAllByRole("tab").map((tab) => tab.textContent);
expect(tabs[0]).toContain("All Models");
expect(tabs[1]).toBe("Add Model");
expect(tabs[2]).toContain("Auto-Routers");
// Badged Beta while the tab settles; BetaBadge renders the label text.
expect(tabs[2]).toContain("Beta");
});
it("renders its panel when selected", async () => {
const user = userEvent.setup();
const { getByRole, getByTestId } = renderPage();
await user.click(getByRole("tab", { name: /Auto-Routers/ }));
expect(getByTestId("panel-auto-routers")).toBeInTheDocument();
});
it("is hidden from non-admins, who cannot write models", () => {
mockUseAuthorized.mockReturnValue(NON_ADMIN);
const { queryByRole } = renderPage();
expect(queryByRole("tab", { name: /Auto-Routers/ })).toBeNull();
});
});
});

View file

@ -7,13 +7,16 @@ import { useQueryClient } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { canCreateModels } from "@/utils/modelPermissions";
import BetaBadge from "@/components/BetaBadge";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import ModelInfoView from "@/components/model_info_view";
import TeamInfoView from "@/components/team/TeamInfo";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel";
import AutoRoutersTabPanel from "@/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel";
import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel";
import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel";
import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel";
@ -24,6 +27,7 @@ import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceD
type ModelTabSlug =
| "add"
| "auto-routers"
| "llm-credentials"
| "pass-through"
| "health"
@ -35,6 +39,7 @@ const BASE_TAB_KEY = "all-models";
const TAB_LABELS: Record<ModelTabSlug, string> = {
add: "Add Model",
"auto-routers": "Auto-Routers",
"llm-credentials": "LLM Credentials",
"pass-through": "Pass-Through Endpoints",
health: "Health Status",
@ -47,6 +52,8 @@ const renderPanel = (key: string) => {
switch (key) {
case BASE_TAB_KEY:
return <AllModelsPanel />;
case "auto-routers":
return <AutoRoutersTabPanel />;
case "add":
return <AddModelPanel />;
case "llm-credentials":
@ -77,31 +84,48 @@ export default function ModelsAndEndpointsPage() {
const [activeKey, setActiveKey] = useState<string>(BASE_TAB_KEY);
const [lastRefreshed, setLastRefreshed] = useState("");
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
const isInternalUser = userRole && internalUserRoles.includes(userRole);
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
const addModelDisabledForInternalUsers =
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
const canCreate = canCreateModels(
{ userRole, userID },
{
teams: teams ?? null,
disabledForInternalUsers:
isInternalUser === true && uiSettings?.values?.disable_model_add_for_internal_users === true,
},
);
const isAdmin = all_admin_roles.includes(userRole);
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
() => [
"",
...(shouldHideAddModelTab ? [] : (["add"] as const)),
...(canCreate ? (["add"] as const) : []),
...(isAdmin || canCreate ? (["auto-routers"] as const) : []),
...(isAdmin
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
: []),
],
[shouldHideAddModelTab, isAdmin],
[canCreate, isAdmin],
);
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
// Auto-Routers carries a Beta badge; BetaBadge honours the admin setting that hides these.
const tabLabel = (slug: "" | ModelTabSlug): React.ReactNode => {
if (!slug) return allModelsLabel;
if (slug === "auto-routers") {
return (
<span className="flex items-center gap-2">
{TAB_LABELS[slug]} <BetaBadge />
</span>
);
}
return TAB_LABELS[slug];
};
const tabItems = visibleSlugs.map((slug) => {
const key = slug || BASE_TAB_KEY;
return {
key,
label: slug ? TAB_LABELS[slug] : allModelsLabel,
label: tabLabel(slug),
children: key === activeKey ? renderPanel(key) : null,
};
});

View file

@ -3,7 +3,7 @@
import { Form } from "antd";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import AddModelTab from "@/components/add_model/add_model_tab";
import AddModelForm from "@/components/add_model/AddModelForm";
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
import NotificationsManager from "@/components/molecules/notifications_manager";
@ -14,7 +14,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
export default function AddModelPanel() {
const { accessToken, userRole } = useAuthorized();
const { accessToken } = useAuthorized();
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: modelCostMapData } = useModelCostMap();
@ -39,7 +39,7 @@ export default function AddModelPanel() {
};
return (
<AddModelTab
<AddModelForm
form={form}
handleOk={handleOk}
selectedProvider={selectedProvider}
@ -52,8 +52,6 @@ export default function AddModelPanel() {
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams ?? null}
credentials={credentialsResponse?.credentials || []}
accessToken={accessToken}
userRole={userRole}
/>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { internalUserRoles } from "@/utils/roles";
import { modelCreationScope } from "@/utils/modelPermissions";
import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel";
/**
* Owns the permission decision for the Auto-Routers tab so the panel stays a renderer.
* Creating an auto router is a POST /model/new, the same endpoint Add Model posts to, so it
* takes the same audience rule: a proxy admin, or a team admin who scopes it to a team.
* Viewer roles reach the list without write affordances.
*/
export default function AutoRoutersTabPanel() {
const { accessToken, userRole, userId: userID } = useAuthorized();
const { data: teams } = useTeams();
const { data: uiSettings } = useUISettings();
const isInternalUser = userRole != null && internalUserRoles.includes(userRole);
const scope = modelCreationScope(
{ userRole, userID },
{
teams: teams ?? null,
disabledForInternalUsers: isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true,
},
);
return (
<AutoRoutersPanel
accessToken={accessToken}
userRole={userRole ?? ""}
userID={userID ?? null}
teams={teams ?? null}
createScope={scope}
/>
);
}

View file

@ -13,7 +13,6 @@ vi.mock("@/components/networking", () => ({
vi.mock("@/components/router_settings", () => ({ default: () => null }));
vi.mock("@/components/Settings/RouterSettings/Fallbacks/Fallbacks", () => ({ default: () => null }));
vi.mock("@/components/routing_groups", () => ({ default: () => null }));
// Mirrors the /config/list ordering: the two prompt-caching rows sit between the
// General-tab rows in the unfiltered response but are filtered out of the General
// tab's table, so any index-based lookup into the unfiltered array reads the wrong
@ -99,3 +98,19 @@ describe("GeneralSettings General tab", () => {
expect(within(row).getByRole("spinbutton")).toHaveValue("1.00");
});
});
// The five tabs here are proxy-wide settings. Auto-routers moved to Models + Endpoints.
describe("GeneralSettings tabs", () => {
beforeEach(() => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
});
it("renders the proxy-wide tabs and no auto-router tab", async () => {
renderWithProviders(<GeneralSettings accessToken="token" userRole="proxy_admin" userID="u" />);
for (const name of ["Loadbalancing", "Routing Groups", "Fallbacks", "Prompt Caching", "General"]) {
expect(await screen.findByRole("tab", { name })).toBeInTheDocument();
}
expect(screen.queryByRole("tab", { name: /auto.?router/i })).not.toBeInTheDocument();
});
});

View file

@ -13,7 +13,7 @@ import {
Icon,
Switch,
} from "@tremor/react";
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
import { InputNumber, Select as AntdSelect } from "antd";
import { TrashIcon } from "@heroicons/react/outline";
@ -232,82 +232,80 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
return (
<div className="w-full">
<TabGroup className="h-[75vh] w-full">
<TabList variant="line" defaultValue="1" className="px-8 pt-4">
<Tab value="1">Loadbalancing</Tab>
<Tab value="2">Routing Groups</Tab>
<Tab value="3">Fallbacks</Tab>
<Tab value="5">Prompt Caching</Tab>
<Tab value="4">General</Tab>
</TabList>
<TabPanels className="px-8 py-6">
<TabPanel>
<RouterSettings accessToken={accessToken} userRole={userRole} userID={userID} />
</TabPanel>
<TabPanel>
<RoutingGroups />
</TabPanel>
<TabPanel>
<Fallbacks accessToken={accessToken} userRole={userRole} userID={userID} />
</TabPanel>
<TabPanel>
<PromptCachingPanel accessToken={accessToken} settings={generalSettings} onChange={handleInputChange} />
</TabPanel>
<TabPanel>
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Setting</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{generalSettings
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
.map((value, index) => (
<TableRow key={index}>
<TableCell>
<Text>{value.field_name}</Text>
<p
style={{
fontSize: "0.65rem",
color: "#808080",
fontStyle: "italic",
}}
className="mt-1"
>
{value.field_description}
</p>
</TableCell>
<TableCell>
<SettingValueEditor setting={value} onChange={handleInputChange} />
</TableCell>
<TableCell>
{value.stored_in_db == true ? (
<StatusBadge tone="success" label="In DB" />
) : value.stored_in_db == false ? (
<StatusBadge tone="neutral" label="In Config" />
) : (
<StatusBadge tone="neutral" label="Not Set" />
)}
</TableCell>
<TableCell>
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name)}>
Reset
</Icon>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
<Tabs defaultValue="loadbalancing" className="h-[75vh] w-full">
<TabsList variant="line" className="mx-8 mt-4">
<TabsTrigger value="loadbalancing">Loadbalancing</TabsTrigger>
<TabsTrigger value="routing-groups">Routing Groups</TabsTrigger>
<TabsTrigger value="fallbacks">Fallbacks</TabsTrigger>
<TabsTrigger value="prompt-caching">Prompt Caching</TabsTrigger>
<TabsTrigger value="general">General</TabsTrigger>
</TabsList>
<TabsContent value="loadbalancing" className="px-8 py-6">
<RouterSettings accessToken={accessToken} userRole={userRole} userID={userID} />
</TabsContent>
<TabsContent value="routing-groups" className="px-8 py-6">
<RoutingGroups />
</TabsContent>
<TabsContent value="fallbacks" className="px-8 py-6">
<Fallbacks accessToken={accessToken} userRole={userRole} userID={userID} />
</TabsContent>
<TabsContent value="prompt-caching" className="px-8 py-6">
<PromptCachingPanel accessToken={accessToken} settings={generalSettings} onChange={handleInputChange} />
</TabsContent>
<TabsContent value="general" className="px-8 py-6">
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Setting</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{generalSettings
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
.map((value, index) => (
<TableRow key={index}>
<TableCell>
<Text>{value.field_name}</Text>
<p
style={{
fontSize: "0.65rem",
color: "#808080",
fontStyle: "italic",
}}
className="mt-1"
>
{value.field_description}
</p>
</TableCell>
<TableCell>
<SettingValueEditor setting={value} onChange={handleInputChange} />
</TableCell>
<TableCell>
{value.stored_in_db == true ? (
<StatusBadge tone="success" label="In DB" />
) : value.stored_in_db == false ? (
<StatusBadge tone="neutral" label="In Config" />
) : (
<StatusBadge tone="neutral" label="Not Set" />
)}
</TableCell>
<TableCell>
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name)}>
Reset
</Icon>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -297,4 +297,17 @@ describe("AddModelForm", () => {
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
});
it("should display the provider field and the Test Connect / Add Model buttons", async () => {
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
const props = createTestProps();
renderWithProviders(<AddModelForm {...props} />);
expect(await screen.findByText("Provider")).toBeInTheDocument();
expect((await screen.findAllByRole("button", { name: "Test Connect" })).length).toBeGreaterThan(0);
expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument();
});
});

View file

@ -2,6 +2,7 @@ import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProvider
import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { modelCreationScope } from "@/utils/modelPermissions";
import { Switch, Text } from "@tremor/react";
import type { FormInstance } from "antd";
import { Select as AntdSelect, Button, Card, Col, Form, Modal, Row, Tooltip, Typography, Alert } from "antd";
@ -101,6 +102,10 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
const isAdmin = all_admin_roles.includes(userRole);
const isTeamAdmin = isUserTeamAdminForAnyTeam(teams, userId);
// Same owner the Auto-Routers tab uses, so the two creation forms cannot disagree about
// who has to name a team. This form is only reachable when creation is allowed at all.
const createScope = modelCreationScope({ userRole, userID: userId }, { teams, disabledForInternalUsers: false });
const requiresTeamScope = createScope === "team-required";
return (
<>
@ -120,7 +125,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
labelAlign="left"
>
<>
{isTeamAdmin && !isAdmin && (
{requiresTeamScope && (
<>
<Form.Item
label="Select Team"
@ -307,7 +312,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
)}
{/* Conditional Team Selection */}
{isTeamOnly && (isAdmin || !isTeamAdmin) && (
{isTeamOnly && !requiresTeamScope && (
<Form.Item
label="Select Team"
name="team_id"

View file

@ -1,9 +1,10 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { Form } from "antd";
import AddAutoRouterTab from "./add_auto_router_tab";
import NotificationManager from "../molecules/notifications_manager";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { getMissingTiersError } from "./build_complexity_router_config";
vi.mock("../networking", () => ({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
@ -21,12 +22,36 @@ vi.mock("../molecules/notifications_manager", () => ({
default: { fromBackend: vi.fn() },
}));
const Harness = () => {
const [form] = Form.useForm();
return <AddAutoRouterTab form={form} handleOk={vi.fn()} accessToken="token" userRole="Admin" />;
};
// Kept real by default so the "mandatory field" test still sees genuine tier validation; one
// test overrides it to reach the submit path without driving four tier selects.
vi.mock("./build_complexity_router_config", async (importOriginal) => {
const actual = await importOriginal<typeof import("./build_complexity_router_config")>();
return { ...actual, getMissingTiersError: vi.fn(actual.getMissingTiersError) };
});
// A real TeamDropdown fetches teams and renders an antd Select; the wiring under test is
// whether team_id is registered, validated and forwarded, so a plain control stands in.
vi.mock("../common_components/team_dropdown", () => ({
default: ({ value, onChange }: { value?: string; onChange?: (next: string) => void }) => (
<select
data-testid="team-dropdown"
value={value ?? ""}
onChange={(event) => onChange?.(event.target.value)}
aria-label="Select Team"
>
<option value="">none</option>
<option value="team-1">team-1</option>
</select>
),
}));
const Harness = () => <AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Admin" />;
describe("AddAutoRouterTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => {
const user = userEvent.setup();
renderWithProviders(<Harness />);
@ -37,4 +62,53 @@ describe("AddAutoRouterTab", () => {
expect(screen.getAllByText("This tier is required")).toHaveLength(4);
expect(NotificationManager.fromBackend).toHaveBeenCalledWith("Please enter an Auto Router Name");
});
it("offers no team selector to a proxy admin, who may create an unscoped router", () => {
renderWithProviders(<Harness />);
expect(screen.queryByTestId("team-dropdown")).not.toBeInTheDocument();
});
it("requires a team admin to pick a team", async () => {
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
expect(screen.getByTestId("team-dropdown")).toBeInTheDocument();
expect(screen.getByText("Select Team")).toBeInTheDocument();
});
// POST /model/new 403s an unscoped create from a non-proxy-admin, so a selected team that
// never reaches the payload is indistinguishable from having no selector at all. The value
// has to survive form.validateFields, which only returns the fields it is asked for.
it("carries the selected team through to the create payload", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
await user.selectOptions(screen.getByTestId("team-dropdown"), "team-1");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]).toMatchObject({ team_id: "team-1" });
});
it("blocks the submit when a team admin has not picked a team", async () => {
const user = userEvent.setup();
vi.mocked(getMissingTiersError).mockReturnValue(null);
renderWithProviders(
<AddAutoRouterTab handleOk={vi.fn()} accessToken="token" userRole="Internal User" createScope="team-required" />,
);
await user.type(screen.getByPlaceholderText(/smart_router/i), "team-scoped-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
expect(await screen.findByText("Please select a team to continue")).toBeInTheDocument();
expect(handleAddAutoRouterSubmit).not.toHaveBeenCalled();
});
});

View file

@ -1,13 +1,12 @@
import React, { useEffect, useState } from "react";
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Radio, Badge, Space, Modal } from "antd";
import type { FormInstance } from "antd";
import { ThunderboltOutlined, BranchesOutlined } from "@ant-design/icons";
import { Text, TextInput } from "@tremor/react";
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd";
import { TextInput } from "@tremor/react";
import { modelAvailableCall } from "../networking";
import { all_admin_roles } from "@/utils/roles";
import { type ModelWriteScope } from "@/utils/modelPermissions";
import TeamDropdown from "../common_components/team_dropdown";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "./RouterConfigBuilder";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
DEFAULT_ADAPTIVE_WEIGHTS,
@ -22,27 +21,34 @@ import {
getSemanticConfigError,
} from "./build_complexity_router_config";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import { getSemanticRouterError } from "./build_semantic_router_validation";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import NotificationManager from "../molecules/notifications_manager";
interface AddAutoRouterTabProps {
form: FormInstance;
handleOk: () => void;
accessToken: string;
userRole: string;
/**
* How this caller must scope what they create. A team admin has to name a team, because
* POST /model/new rejects an unscoped create from any non-proxy-admin; without the selector
* their submit is a guaranteed 403.
*/
createScope?: ModelWriteScope;
}
type RouterType = "recommended" | "semantic";
const { Title } = Typography;
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, accessToken, userRole }) => {
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
handleOk,
accessToken,
userRole,
createScope = "unscoped-ok",
}) => {
const requiresTeamScope = createScope === "team-required";
const [form] = Form.useForm();
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [routerType, setRouterType] = useState<RouterType>("recommended");
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
@ -56,9 +62,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
const [escalationKeywords, setEscalationKeywords] = useState<string[]>(DEFAULT_ESCALATION_KEYWORDS);
const [showValidationErrors, setShowValidationErrors] = useState<boolean>(false);
// Semantic router config (existing)
const [routerConfig, setRouterConfig] = useState<any>(null);
const [isTestModalVisible, setIsTestModalVisible] = useState<boolean>(false);
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
const [connectionTestId, setConnectionTestId] = useState<number>(0);
@ -133,7 +136,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
});
form
.validateFields(["auto_router_name"])
.validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
.then((values) => {
const complexityRouterConfigParams = {
tiers,
@ -169,40 +172,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
});
};
const submitSemanticRouter = (name: string) => {
const validationError = getSemanticRouterError({
defaultModel: form.getFieldValue("auto_router_default_model"),
embeddingModel: form.getFieldValue("auto_router_embedding_model"),
routerConfig,
});
if (validationError) {
NotificationManager.fromBackend(validationError);
return;
}
form.setFieldsValue({
custom_llm_provider: "auto_router",
model: name,
api_key: "not_required_for_auto_router",
});
form
.validateFields()
.then((values) => {
const submitValues = {
...values,
auto_router_name: name,
auto_router_config: routerConfig,
model_type: "semantic_router",
};
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
})
.catch((error) => {
console.error("Validation failed:", error);
NotificationManager.fromBackend("Please fill in all required fields");
});
};
const handleAutoRouterSubmit = () => {
const name = form.getFieldValue("auto_router_name");
if (!name) {
@ -212,11 +181,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
return;
}
if (routerType === "recommended") {
submitRecommendedRouter(name);
} else {
submitSemanticRouter(name);
}
submitRecommendedRouter(name);
};
const handleTestConnection = () => {
@ -239,53 +204,6 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
return (
<>
<Title level={2}>Add Auto Router</Title>
<Text className="text-gray-600 mb-6">
Create an auto router that automatically selects the best model based on request complexity or semantic
matching. Use in place of a single default model.
</Text>
<Card className="mb-4">
<div className="mb-4">
<Text className="text-sm font-medium mb-2 block">Router Type</Text>
<Radio.Group
value={routerType}
onChange={(e) => {
setRouterType(e.target.value);
setShowValidationErrors(false);
}}
className="w-full"
>
<Space direction="vertical" className="w-full">
<Radio value="recommended" className="w-full">
<div className="flex items-center gap-2">
<ThunderboltOutlined className="text-yellow-500" />
<span className="font-medium">Auto-Router v2</span>
<Badge
count="Recommended"
style={{ backgroundColor: "#52c41a", fontSize: "10px", padding: "0 6px" }}
/>
</div>
<div className="text-xs text-gray-500 ml-6 mt-1">
Routes by request complexity across four tiers, with optional keyword-to-tier overrides and semantic
keyword matching. No training data needed.
</div>
</Radio>
<Radio value="semantic" className="w-full mt-2">
<div className="flex items-center gap-2">
<BranchesOutlined className="text-blue-500" />
<span className="font-medium">Semantic Router [to be deprecated]</span>
</div>
<div className="text-xs text-gray-500 ml-6 mt-1">
Routes based on semantic similarity to example utterances. Requires an embedding model and example
utterances.
</div>
</Radio>
</Space>
</Radio.Group>
</div>
</Card>
<Card>
<Form
form={form}
@ -305,74 +223,40 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<TextInput placeholder="e.g., smart_router, auto_router_1" />
</Form.Item>
{routerType === "recommended" ? (
<div className="w-full mb-4">
<ComplexityRouterConfig
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
showValidationErrors={showValidationErrors}
/>
</div>
) : (
<>
<div className="w-full mb-4">
<RouterConfigBuilder
modelInfo={modelInfo}
value={routerConfig}
onChange={(config) => {
setRouterConfig(config);
form.setFieldValue("auto_router_config", config);
}}
/>
</div>
<Form.Item
rules={[{ required: true, message: "Default model is required" }]}
label="Default Model"
name="auto_router_default_model"
tooltip="Fallback model to use when auto routing logic cannot determine the best model"
labelCol={{ span: 10 }}
labelAlign="left"
>
<AntdSelect
placeholder="Select a default model"
options={modelGroupOptions}
style={{ width: "100%" }}
showSearch
/>
</Form.Item>
<Form.Item
rules={[{ required: true, message: "Embedding model is required" }]}
label="Embedding Model"
name="auto_router_embedding_model"
tooltip="Embedding model to use for semantic routing decisions"
labelCol={{ span: 10 }}
labelAlign="left"
>
<AntdSelect
placeholder="Select an embedding model"
options={modelGroupOptions}
style={{ width: "100%" }}
showSearch
/>
</Form.Item>
</>
{requiresTeamScope && (
<Form.Item
label="Select Team"
name="team_id"
rules={[{ required: true, message: "Please select a team to continue" }]}
tooltip="Select the team this auto router belongs to. Only keys for this team will be able to call it."
labelCol={{ span: 10 }}
labelAlign="left"
>
<TeamDropdown />
</Form.Item>
)}
<div className="w-full mb-4">
<ComplexityRouterConfig
modelInfo={modelInfo}
value={complexityRouterConfig}
onChange={setComplexityRouterConfig}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
showValidationErrors={showValidationErrors}
/>
</div>
<div className="flex items-center my-4">
<div className="grow border-t border-gray-200"></div>
<span className="px-4 text-gray-500 text-sm">Additional Settings</span>
@ -408,7 +292,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
<Typography.Link href="https://github.com/BerriAI/litellm/issues">Need Help?</Typography.Link>
</Tooltip>
<div className="space-x-2">
{routerType === "recommended" && (
{
<Button
data-testid="auto-router-test-connect-btn"
onClick={handleTestConnection}
@ -416,7 +300,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({ form, handleOk, acc
>
Test Connection
</Button>
)}
}
<Button
type="primary"
onClick={() => {

View file

@ -1,318 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Form } from "antd";
import type { UploadProps } from "antd/es/upload";
import { describe, expect, it, vi } from "vitest";
import type { Team } from "../key_team_helpers/key_list";
import type { CredentialItem } from "../networking";
import { Providers } from "../provider_info_helpers";
import AddModelTab from "./add_model_tab";
vi.mock("../molecules/models/ProviderLogo", () => ({
ProviderLogo: ({ provider, className }: { provider: string; className?: string }) => (
<div className={className} data-testid={`provider-logo-${provider}`}>
{provider}
</div>
),
}));
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
...actual,
getGuardrailsList: vi.fn().mockResolvedValue({
guardrails: [{ guardrail_name: "test-guardrail-1" }, { guardrail_name: "test-guardrail-2" }],
}),
tagListCall: vi.fn().mockResolvedValue({}),
modelAvailableCall: vi.fn().mockResolvedValue({
data: [{ id: "model-group-1" }, { id: "model-group-2" }],
}),
modelHubCall: vi.fn().mockResolvedValue({
data: [
{ model_group: "gpt-4", mode: "chat" },
{ model_group: "gpt-3.5-turbo", mode: "chat" },
],
}),
getProviderCreateMetadata: vi.fn().mockResolvedValue([
{
provider: "OpenAI",
provider_display_name: "OpenAI",
litellm_provider: "openai",
default_model_placeholder: "gpt-3.5-turbo",
credential_fields: [],
},
]),
};
});
vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({
useProviderFields: vi.fn().mockReturnValue({
data: [
{
provider: "OpenAI",
provider_display_name: "OpenAI",
litellm_provider: "openai",
default_model_placeholder: "gpt-3.5-turbo",
credential_fields: [],
},
],
isLoading: false,
error: null,
}),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn().mockReturnValue({
accessToken: "test-access-token",
userRole: "Admin",
premiumUser: true,
}),
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
},
},
});
const createTestProps = () => {
const { result } = renderHook(() => Form.useForm());
const [form] = result.current;
const handleOk = vi.fn();
const setSelectedProvider = vi.fn();
const setProviderModelsFn = vi.fn();
const getPlaceholder = vi.fn((provider: Providers) => `Enter ${provider} model name`);
const setShowAdvancedSettings = vi.fn();
const selectedProvider = Providers.OpenAI;
const providerModels = ["gpt-4", "gpt-3.5-turbo"];
const showAdvancedSettings = false;
const teams: Team[] = [
{
team_id: "team-1",
team_alias: "Test Team",
models: ["gpt-4"],
max_budget: 100,
budget_duration: "monthly",
tpm_limit: null,
rpm_limit: null,
organization_id: "org-1",
created_at: "2024-01-01T00:00:00Z",
keys: [],
members_with_roles: [],
},
];
const credentials: CredentialItem[] = [
{
credential_name: "test-credential",
credential_values: {},
credential_info: {
custom_llm_provider: "openai",
description: "Test credential",
},
},
];
const uploadProps: UploadProps = {
beforeUpload: () => false,
showUploadList: false,
};
return {
form,
handleOk,
setSelectedProvider,
setProviderModelsFn,
getPlaceholder,
setShowAdvancedSettings,
selectedProvider,
providerModels,
showAdvancedSettings,
teams,
credentials,
uploadProps,
accessToken: "test-access-token",
userRole: "Admin",
};
};
describe("Add Model Tab", () => {
it("should render", async () => {
const props = createTestProps();
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<AddModelTab
form={props.form}
handleOk={props.handleOk}
selectedProvider={props.selectedProvider}
setSelectedProvider={props.setSelectedProvider}
providerModels={props.providerModels}
setProviderModelsFn={props.setProviderModelsFn}
getPlaceholder={props.getPlaceholder}
uploadProps={props.uploadProps}
showAdvancedSettings={props.showAdvancedSettings}
setShowAdvancedSettings={props.setShowAdvancedSettings}
teams={props.teams}
credentials={props.credentials}
accessToken={props.accessToken}
userRole={props.userRole}
/>
</QueryClientProvider>,
);
expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
});
it("should display both Add Model and Add Auto Router tabs", async () => {
const props = createTestProps();
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<AddModelTab
form={props.form}
handleOk={props.handleOk}
selectedProvider={props.selectedProvider}
setSelectedProvider={props.setSelectedProvider}
providerModels={props.providerModels}
setProviderModelsFn={props.setProviderModelsFn}
getPlaceholder={props.getPlaceholder}
uploadProps={props.uploadProps}
showAdvancedSettings={props.showAdvancedSettings}
setShowAdvancedSettings={props.setShowAdvancedSettings}
teams={props.teams}
credentials={props.credentials}
accessToken={props.accessToken}
userRole={props.userRole}
/>
</QueryClientProvider>,
);
expect(await screen.findByRole("tab", { name: "Add Model" })).toBeInTheDocument();
expect(await screen.findByRole("tab", { name: "Add Auto Router" })).toBeInTheDocument();
});
it("should display provider selection field", async () => {
const props = createTestProps();
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<AddModelTab
form={props.form}
handleOk={props.handleOk}
selectedProvider={props.selectedProvider}
setSelectedProvider={props.setSelectedProvider}
providerModels={props.providerModels}
setProviderModelsFn={props.setProviderModelsFn}
getPlaceholder={props.getPlaceholder}
uploadProps={props.uploadProps}
showAdvancedSettings={props.showAdvancedSettings}
setShowAdvancedSettings={props.setShowAdvancedSettings}
teams={props.teams}
credentials={props.credentials}
accessToken={props.accessToken}
userRole={props.userRole}
/>
</QueryClientProvider>,
);
expect(await screen.findByText("Provider")).toBeInTheDocument();
});
it("should display Test Connect and Add Model buttons", async () => {
const props = createTestProps();
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<AddModelTab
form={props.form}
handleOk={props.handleOk}
selectedProvider={props.selectedProvider}
setSelectedProvider={props.setSelectedProvider}
providerModels={props.providerModels}
setProviderModelsFn={props.setProviderModelsFn}
getPlaceholder={props.getPlaceholder}
uploadProps={props.uploadProps}
showAdvancedSettings={props.showAdvancedSettings}
setShowAdvancedSettings={props.setShowAdvancedSettings}
teams={props.teams}
credentials={props.credentials}
accessToken={props.accessToken}
userRole={props.userRole}
/>
</QueryClientProvider>,
);
// Wait for async operations to complete and buttons to appear
await waitFor(
async () => {
const testConnectButtons = await screen.findAllByRole("button", { name: "Test Connect" });
expect(testConnectButtons.length).toBeGreaterThan(0);
const addModelButton = await screen.findByRole("button", { name: "Add Model" });
expect(addModelButton).toBeInTheDocument();
},
{ timeout: 10000 },
);
});
it("should show team selection when team-only switch is enabled", async () => {
const props = createTestProps();
const queryClient = createQueryClient();
render(
<QueryClientProvider client={queryClient}>
<AddModelTab
form={props.form}
handleOk={props.handleOk}
selectedProvider={props.selectedProvider}
setSelectedProvider={props.setSelectedProvider}
providerModels={props.providerModels}
setProviderModelsFn={props.setProviderModelsFn}
getPlaceholder={props.getPlaceholder}
uploadProps={props.uploadProps}
showAdvancedSettings={props.showAdvancedSettings}
setShowAdvancedSettings={props.setShowAdvancedSettings}
teams={props.teams}
credentials={props.credentials}
accessToken={props.accessToken}
userRole={props.userRole}
/>
</QueryClientProvider>,
);
// Wait for component to load
await screen.findByText("Provider");
// Scope to the Team-BYOK Model Form.Item: the Add Auto Router tab, mounted alongside
// this one, also renders a "Semantic keyword matching" switch, so a bare
// getByRole("switch") would match more than one element.
const teamByokFormItem = screen.getByText("Team-BYOK Model").closest(".ant-form-item") as HTMLElement;
const teamSwitch = within(teamByokFormItem).getByRole("switch");
expect(teamSwitch).toBeInTheDocument();
// Initially, team selection should not be visible
expect(screen.queryByText("Select Team")).not.toBeInTheDocument();
// Click the switch to enable team-only mode
await userEvent.click(teamSwitch!);
// Now team selection should be visible
expect(await screen.findByText("Select Team")).toBeInTheDocument();
});
});

View file

@ -1,98 +0,0 @@
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import type { FormInstance } from "antd";
import { Form } from "antd";
import type { UploadProps } from "antd/es/upload";
import React from "react";
import type { Team } from "../key_team_helpers/key_list";
import { type CredentialItem } from "../networking";
import { Providers } from "../provider_info_helpers";
import AddAutoRouterTab from "./add_auto_router_tab";
import AddModelForm from "./AddModelForm";
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
interface AddModelTabProps {
form: FormInstance; // For the Add Model tab
handleOk: (values?: any) => Promise<void>;
selectedProvider: Providers;
setSelectedProvider: (provider: Providers) => void;
providerModels: string[];
setProviderModelsFn: (provider: Providers) => void;
getPlaceholder: (provider: Providers) => string;
uploadProps: UploadProps;
showAdvancedSettings: boolean;
setShowAdvancedSettings: (show: boolean) => void;
teams: Team[] | null;
credentials: CredentialItem[];
accessToken: string;
userRole: string;
}
const AddModelTab: React.FC<AddModelTabProps> = ({
form,
handleOk,
selectedProvider,
setSelectedProvider,
providerModels,
setProviderModelsFn,
getPlaceholder,
uploadProps,
showAdvancedSettings,
setShowAdvancedSettings,
teams,
credentials,
accessToken,
userRole,
}) => {
// Create separate form instance for auto router
const [autoRouterForm] = Form.useForm();
const handleAutoRouterOk = () => {
autoRouterForm
.validateFields()
.then((values) => {
handleAddAutoRouterSubmit(values, accessToken, autoRouterForm, handleOk);
})
.catch((error) => {
console.error("Validation failed:", error);
});
};
return (
<>
<TabGroup className="w-full">
<TabList className="mb-4">
<Tab>Add Model</Tab>
<Tab>Add Auto Router</Tab>
</TabList>
<TabPanels>
<TabPanel>
<AddModelForm
form={form}
handleOk={handleOk}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
providerModels={providerModels}
setProviderModelsFn={setProviderModelsFn}
getPlaceholder={getPlaceholder}
uploadProps={uploadProps}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams}
credentials={credentials}
/>
</TabPanel>
<TabPanel>
<AddAutoRouterTab
form={autoRouterForm}
handleOk={handleAutoRouterOk}
accessToken={accessToken}
userRole={userRole}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</>
);
};
export default AddModelTab;

View file

@ -0,0 +1,143 @@
/**
* Single owner of "which auto-router strategy is this deployment, and what can we do with it".
*
* Two independent axes decide whether a row is writable, and both must hold:
* 1. STRATEGY - the dashboard only has a form for complexity and semantic routers. Adaptive
* and quality store their settings under their own config keys, so opening
* one in the complexity/semantic editor would write the wrong shape onto it.
* 2. ORIGIN - a deployment defined in config.yaml reports `db_model: false`, and the API
* refuses it regardless of strategy (PATCH /model/{id}/update 404s,
* POST /model/delete 400s). Only rows created through the API are writable.
*
* Strategy order mirrors Router._is_auto_router_deployment (router.py:7589-7594): the named
* prefixes are matched first, and only a bare `auto_router/<name>` is the semantic router.
*/
export type AutoRouterKind = "complexity" | "adaptive" | "quality" | "semantic";
export interface AutoRouterParams {
model?: string | null;
complexity_router_config?: unknown;
complexity_router_default_model?: string | null;
auto_router_config?: unknown;
auto_router_default_model?: string | null;
adaptive_router_config?: unknown;
adaptive_router_default_model?: string | null;
quality_router_config?: unknown;
quality_router_default_model?: string | null;
}
export interface AutoRouterStrategy {
kind: AutoRouterKind;
/** Type-pill label. The complexity router overrides this with its classifier. */
label: string;
configKey: keyof AutoRouterParams;
defaultModelKey: keyof AutoRouterParams;
/** Whether the dashboard has a form that understands this strategy's config shape. */
hasEditor: boolean;
matches: (params: AutoRouterParams) => boolean;
}
const startsWith = (params: AutoRouterParams, prefix: string): boolean => params.model?.startsWith(prefix) === true;
/** Ordered; the semantic entry matches anything left and must stay last. */
export const AUTO_ROUTER_STRATEGIES: readonly AutoRouterStrategy[] = [
{
kind: "complexity",
label: "Complexity",
configKey: "complexity_router_config",
defaultModelKey: "complexity_router_default_model",
hasEditor: true,
// Also matched by config presence: rows predating the canonical model string carry the
// config without the prefix.
matches: (p) => startsWith(p, "auto_router/complexity_router") || p.complexity_router_config != null,
},
{
kind: "adaptive",
label: "Adaptive",
configKey: "adaptive_router_config",
defaultModelKey: "adaptive_router_default_model",
hasEditor: false,
matches: (p) => startsWith(p, "auto_router/adaptive_router"),
},
{
kind: "quality",
label: "Quality",
configKey: "quality_router_config",
defaultModelKey: "quality_router_default_model",
hasEditor: false,
matches: (p) => startsWith(p, "auto_router/quality_router"),
},
{
kind: "semantic",
label: "Semantic",
configKey: "auto_router_config",
defaultModelKey: "auto_router_default_model",
hasEditor: true,
matches: () => true,
},
] as const;
export const autoRouterStrategy = (params: AutoRouterParams | null | undefined): AutoRouterStrategy =>
AUTO_ROUTER_STRATEGIES.find((strategy) => strategy.matches(params ?? {}))!;
export const isComplexityRouter = (params: AutoRouterParams | null | undefined): boolean =>
autoRouterStrategy(params).kind === "complexity";
/** Any `auto_router/*` deployment, whatever its strategy. Use for listing and filtering. */
export const isAutoRouterDeployment = (params: AutoRouterParams | null | undefined): boolean =>
params?.model?.startsWith("auto_router/") === true ||
params?.complexity_router_config != null ||
params?.auto_router_config != null;
/**
* Whether EditAutoRouterModal understands this deployment. It only speaks complexity and
* semantic, so offering it for an adaptive or quality router lets a save write
* `auto_router_config` onto a row that stores its settings elsewhere. Gate every edit
* affordance on this, never on `isAutoRouterDeployment`.
*/
export const hasAutoRouterEditor = (params: AutoRouterParams | null | undefined): boolean =>
isAutoRouterDeployment(params) && autoRouterStrategy(params).hasEditor;
export interface AutoRouterDeploymentInfo {
db_model?: boolean | null;
}
/** Why the dashboard cannot offer an edit form, or null when it can. */
export type EditBlockedReason = "config-managed" | "no-editor";
export interface AutoRouterCapabilities {
/** Defined in config.yaml; the API refuses both update and delete for it. */
isConfigManaged: boolean;
canEdit: boolean;
/** Deleting removes a row by id and never reads its config, so strategy is irrelevant. */
canDelete: boolean;
editBlockedReason: EditBlockedReason | null;
}
/**
* What could be done to this deployment by anyone with permission. Deliberately excludes the
* caller's role: the page ANDs that in, so resource capability and actor permission stay
* separable. Derive per capability rather than exposing one "editable" boolean, because the
* constraints differ (edit needs an editor, delete does not) and the explanation differs again.
*/
const editBlockedReasonFor = (isConfigManaged: boolean, hasEditor: boolean): EditBlockedReason | null => {
if (isConfigManaged) return "config-managed";
if (!hasEditor) return "no-editor";
return null;
};
export const autoRouterCapabilities = (
params: AutoRouterParams | null | undefined,
modelInfo: AutoRouterDeploymentInfo | null | undefined,
): AutoRouterCapabilities => {
const isConfigManaged = modelInfo?.db_model !== true;
const hasEditor = autoRouterStrategy(params).hasEditor;
return {
isConfigManaged,
canEdit: !isConfigManaged && hasEditor,
canDelete: !isConfigManaged,
editBlockedReason: editBlockedReasonFor(isConfigManaged, hasEditor),
};
};

View file

@ -1,4 +1,5 @@
import { KeywordTierRule } from "./KeywordTierRules";
import { serializeKeywordTierRules } from "./complexity_router_keywords";
import {
AdaptiveEligible,
AdaptiveRouterWeights,
@ -82,12 +83,7 @@ export const buildComplexityRouterConfig = ({
}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
const cleanedEscalationKeywords = escalationKeywords.map((keyword) => keyword.trim()).filter(Boolean);
// Trim keywords and drop empty ones; drop any rule left with no keywords. Clicking
// "Add keyword rule" seeds a rule with an empty keywords list, so without this an
// unfilled row (common in the heuristic flow, where getSemanticConfigError doesn't run)
// would ship keyword_tier_rules the backend validator rejects with a 400.
const cleanedKeywordTierRules = keywordTierRules
.map((rule) => ({ keywords: rule.keywords.map((k) => k.trim()).filter(Boolean), tier: rule.tier }))
.filter((rule) => rule.keywords.length > 0);
const cleanedKeywordTierRules = serializeKeywordTierRules(keywordTierRules);
return {
tiers,

View file

@ -1,67 +0,0 @@
import { getSemanticRouterError, SemanticRouterConfig } from "./build_semantic_router_validation";
const validRouterConfig: SemanticRouterConfig = {
routes: [{ name: "gpt-4o", description: "general chat", utterances: ["hello there"] }],
};
describe("getSemanticRouterError", () => {
it("requires an embedding model once the default model and routes are configured", () => {
expect(
getSemanticRouterError({
defaultModel: "gpt-4o",
embeddingModel: undefined,
routerConfig: validRouterConfig,
}),
).toBe("Please select an Embedding Model");
});
it("treats an empty embedding model string as missing", () => {
expect(
getSemanticRouterError({
defaultModel: "gpt-4o",
embeddingModel: "",
routerConfig: validRouterConfig,
}),
).toBe("Please select an Embedding Model");
});
it("passes when an embedding model is selected", () => {
expect(
getSemanticRouterError({
defaultModel: "gpt-4o",
embeddingModel: "text-embedding-3-large",
routerConfig: validRouterConfig,
}),
).toBeNull();
});
it("flags a missing default model before checking the embedding model", () => {
expect(
getSemanticRouterError({
defaultModel: undefined,
embeddingModel: undefined,
routerConfig: validRouterConfig,
}),
).toBe("Please select a Default Model");
});
it("flags missing routes before checking the embedding model", () => {
expect(
getSemanticRouterError({
defaultModel: "gpt-4o",
embeddingModel: undefined,
routerConfig: { routes: [] },
}),
).toBe("Please configure at least one route for the auto router");
});
it("validates route completeness after the embedding model is set", () => {
expect(
getSemanticRouterError({
defaultModel: "gpt-4o",
embeddingModel: "text-embedding-3-large",
routerConfig: { routes: [{ name: "gpt-4o", description: "", utterances: [] }] },
}),
).toBe("Please ensure all routes have a target model, description, and at least one utterance");
});
});

View file

@ -1,29 +0,0 @@
export interface SemanticRouterRoute {
name?: string;
description?: string;
utterances?: unknown[];
}
export interface SemanticRouterConfig {
routes?: SemanticRouterRoute[];
}
export interface SemanticRouterValidationParams {
defaultModel: string | undefined;
embeddingModel: string | undefined;
routerConfig: SemanticRouterConfig | null | undefined;
}
export const getSemanticRouterError = ({
defaultModel,
embeddingModel,
routerConfig,
}: SemanticRouterValidationParams): string | null => {
if (!defaultModel) return "Please select a Default Model";
if (!routerConfig?.routes || routerConfig.routes.length === 0)
return "Please configure at least one route for the auto router";
if (!embeddingModel) return "Please select an Embedding Model";
if (routerConfig.routes.some((route) => !route.name || !route.description || (route.utterances?.length ?? 0) === 0))
return "Please ensure all routes have a target model, description, and at least one utterance";
return null;
};

View file

@ -0,0 +1,40 @@
import { ComplexityTier, KeywordTierRule } from "./KeywordTierRules";
/**
* Stored shape of a keyword tier rule inside `complexity_router_config`. The UI's
* KeywordTierRule carries an extra `id` used only as a React key, so it is stripped on the
* way out and synthesized on the way back in. Both the create form and the edit modal go
* through here so the two directions cannot drift.
*/
export interface StoredKeywordTierRule {
keywords: string[];
tier: ComplexityTier;
}
const TIERS: ReadonlySet<string> = new Set<ComplexityTier>(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
const asKeywords = (value: unknown): string[] =>
Array.isArray(value)
? value.filter((keyword): keyword is string => typeof keyword === "string").map((keyword) => keyword.trim())
: [];
/**
* Drop the React-only id, trim keywords, and discard rules left empty. "Add keyword rule"
* seeds a row with no keywords, and the backend validator rejects those with a 400.
*/
export const serializeKeywordTierRules = (rules: KeywordTierRule[]): StoredKeywordTierRule[] =>
rules
.map((rule) => ({ keywords: asKeywords(rule.keywords).filter(Boolean), tier: rule.tier }))
.filter((rule) => rule.keywords.length > 0);
export const hydrateKeywordTierRules = (value: unknown): KeywordTierRule[] => {
if (!Array.isArray(value)) return [];
return value.flatMap((entry, index) => {
if (typeof entry !== "object" || entry === null) return [];
const record = entry as Record<string, unknown>;
const keywords = asKeywords(record.keywords).filter(Boolean);
const tier = record.tier;
if (keywords.length === 0 || typeof tier !== "string" || !TIERS.has(tier)) return [];
return [{ id: `stored-${index}`, keywords, tier: tier as ComplexityTier }];
});
};

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { normalizeTierModels } from "./complexity_router_tiers";
// The backend types a tier as `str | list[str]` and widens with
// `models if isinstance(models, list) else [models]`
// (litellm/router_strategy/complexity_router/config.py:255, :441). These cases assert the
// expected verdict per input rather than just agreement between call sites, so the test still
// has teeth if every reader were changed at once.
describe("normalizeTierModels", () => {
it("widens a pinned single model to a one-element pool", () => {
expect(normalizeTierModels("gpt-4o-mini")).toEqual(["gpt-4o-mini"]);
});
it("passes a pool through in order", () => {
expect(normalizeTierModels(["a", "b"])).toEqual(["a", "b"]);
});
it("treats an empty string as no models, not a pool containing an empty name", () => {
expect(normalizeTierModels("")).toEqual([]);
});
it("drops non-string entries rather than typing them as models", () => {
expect(normalizeTierModels(["a", 3, null, "b"])).toEqual(["a", "b"]);
});
it.each([[undefined], [null], [{}], [42]])("returns no models for %s", (value) => {
expect(normalizeTierModels(value)).toEqual([]);
});
});

View file

@ -0,0 +1,14 @@
/**
* A complexity tier maps to `str | list[str]` on the backend
* (litellm/router_strategy/complexity_router/config.py: "string = pin; list = random pick"),
* and the router widens the bare string with `models if isinstance(models, list) else [models]`.
*
* Every UI reader of a STORED complexity_router_config must widen the same way, so this is the
* single owner of that rule. Readers of in-memory ComplexityTiers state are already string[]
* and do not need it.
*/
export const normalizeTierModels = (value: unknown): string[] => {
if (Array.isArray(value)) return value.filter((model): model is string => typeof model === "string");
if (typeof value === "string" && value) return [value];
return [];
};

View file

@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { buildUpdatedComplexityRouterConfig, type KeywordMatchingState } from "./edit_auto_router_modal";
const STORED = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
escalation_keywords: ["urgent", "outage"],
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.72,
// A key no UI control owns; it must survive every save untouched.
some_future_backend_key: { nested: true },
};
const FORM_VALUE = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic" as const,
};
const hydratedState: KeywordMatchingState = {
keywordTierRules: [{ id: "stored-0", keywords: ["invoice", "refund"], tier: "MEDIUM" }],
escalationKeywords: ["urgent", "outage"],
semanticMatchingEnabled: true,
embeddingModel: "voyage-4-large",
matchThreshold: 0.72,
};
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
it("round-trips an untouched edit without changing any keyword-matching value", () => {
// Opening the modal hydrates state from STORED; saving with nothing changed must be a
// no-op. These keys are now MANAGED, so a hydration bug silently wipes them.
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
expect(result.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
expect(result.escalation_keywords).toEqual(["urgent", "outage"]);
expect(result.semantic_keyword_matching).toBe(true);
expect(result.embedding_model).toBe("voyage-4-large");
expect(result.match_threshold).toBe(0.72);
});
it("preserves keys no control owns", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, hydratedState);
expect(result.some_future_backend_key).toEqual({ nested: true });
});
it("persists an edited keyword rule", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
...hydratedState,
keywordTierRules: [{ id: "stored-0", keywords: ["chargeback"], tier: "COMPLEX" }],
});
expect(result.keyword_tier_rules).toEqual([{ keywords: ["chargeback"], tier: "COMPLEX" }]);
});
it("drops a rule left empty rather than shipping one the backend 400s on", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
...hydratedState,
keywordTierRules: [{ id: "new-1", keywords: [" "], tier: "SIMPLE" }],
});
expect(result.keyword_tier_rules).toBeUndefined();
});
it("removes the semantic trio when the toggle is turned off", () => {
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE, undefined, {
...hydratedState,
semanticMatchingEnabled: false,
});
expect(result.semantic_keyword_matching).toBeUndefined();
expect(result.embedding_model).toBeUndefined();
expect(result.match_threshold).toBeUndefined();
});
it("carries stored keyword matching through untouched when the caller owns no such state", () => {
// Any caller that does not render these controls must not have its values dropped just
// because the keys are listed as managed.
const result = buildUpdatedComplexityRouterConfig(STORED, FORM_VALUE);
expect(result.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
expect(result.escalation_keywords).toEqual(["urgent", "outage"]);
expect(result.semantic_keyword_matching).toBe(true);
expect(result.embedding_model).toBe("voyage-4-large");
expect(result.match_threshold).toBe(0.72);
});
});

View file

@ -0,0 +1,121 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "@/../tests/test-utils";
import NotificationsManager from "@/components/molecules/notifications_manager";
import EditAutoRouterModal from "./edit_auto_router_modal";
const { modelPatchUpdateCall, modelAvailableCall } = vi.hoisted(() => ({
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("../networking", () => ({ modelPatchUpdateCall, modelAvailableCall }));
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "gpt-4o-mini" }]),
}));
const STORED_CONFIG = {
tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o-mini"], COMPLEX: ["gpt-4o-mini"], REASONING: ["gpt-4o-mini"] },
classifier_type: "heuristic",
keyword_tier_rules: [{ keywords: ["invoice", "refund"], tier: "MEDIUM" }],
escalation_keywords: ["urgent", "outage"],
semantic_keyword_matching: true,
embedding_model: "voyage-4-large",
match_threshold: 0.72,
};
const MODEL_DATA = {
model_name: "tri-tier-router",
litellm_params: {
model: "auto_router/complexity_router",
complexity_router_config: STORED_CONFIG,
},
model_info: { id: "auto-1", access_groups: [] },
};
const renderModal = () =>
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={MODEL_DATA}
accessToken="token"
userRole="Admin"
/>,
);
const savedConfig = () => {
const [, payload] = modelPatchUpdateCall.mock.calls.at(-1) ?? [];
return payload?.litellm_params?.complexity_router_config;
};
describe("EditAutoRouterModal keyword matching", () => {
beforeEach(() => {
modelPatchUpdateCall.mockClear();
});
it("renders the advanced sections the create form offers", async () => {
renderModal();
expect(await screen.findByText(/Escalation Keywords/i)).toBeInTheDocument();
expect(await screen.findByText(/Keyword\/Semantic Matching/i)).toBeInTheDocument();
});
// These keys are rewritten from form state on save, so if the modal renders the controls
// without hydrating them, an untouched save silently wipes the stored configuration. This
// drives the real component; a test of the payload builder alone cannot see that bug.
it("preserves stored keyword matching through an untouched open-and-save", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText(/Escalation Keywords/i);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled());
const config = savedConfig();
expect(config.keyword_tier_rules).toEqual([{ keywords: ["invoice", "refund"], tier: "MEDIUM" }]);
expect(config.escalation_keywords).toEqual(["urgent", "outage"]);
expect(config.semantic_keyword_matching).toBe(true);
expect(config.embedding_model).toBe("voyage-4-large");
expect(config.match_threshold).toBe(0.72);
});
// The create form blocks this; the edit modal renders the same controls, so it must block it
// too. The backend raises on semantic_keyword_matching without an embedding model or keyword
// rules, so skipping the guard turns a friendly inline message into a raw 400.
it("blocks a save that enables semantic matching with no embedding model", async () => {
const user = userEvent.setup();
renderWithProviders(
<EditAutoRouterModal
isVisible
onCancel={vi.fn()}
onSuccess={vi.fn()}
modelData={{
...MODEL_DATA,
litellm_params: {
...MODEL_DATA.litellm_params,
complexity_router_config: {
...STORED_CONFIG,
semantic_keyword_matching: true,
embedding_model: undefined,
keyword_tier_rules: [{ keywords: ["invoice"], tier: "MEDIUM" }],
},
},
}}
accessToken="token"
userRole="Admin"
/>,
);
await screen.findByText(/Escalation Keywords/i);
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(NotificationsManager.fromBackend).toHaveBeenCalled());
expect(modelPatchUpdateCall).not.toHaveBeenCalled();
});
});

View file

@ -4,6 +4,12 @@ import { Text, TextInput } from "@tremor/react";
import { modelAvailableCall, modelPatchUpdateCall } from "../networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import RouterConfigBuilder from "../add_model/RouterConfigBuilder";
import { normalizeTierModels } from "../add_model/complexity_router_tiers";
import { isComplexityRouter } from "../add_model/auto_router_strategies";
import { getSemanticConfigError } from "../add_model/build_complexity_router_config";
import { KeywordTierRule } from "../add_model/KeywordTierRules";
import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching";
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords";
import ComplexityRouterConfig, {
ComplexityRouterConfigValue,
DEFAULT_ADAPTIVE_WEIGHTS,
@ -11,16 +17,6 @@ import ComplexityRouterConfig, {
} from "../add_model/ComplexityRouterConfig";
import NotificationsManager from "../molecules/notifications_manager";
const isComplexityRouterModel = (modelData: any): boolean =>
modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router") ||
modelData?.litellm_params?.complexity_router_config != null;
const normalizeTierModels = (value: unknown): string[] => {
if (Array.isArray(value)) return value;
if (typeof value === "string" && value) return [value];
return [];
};
interface EditAutoRouterModalProps {
isVisible: boolean;
onCancel: () => void;
@ -30,6 +26,9 @@ interface EditAutoRouterModalProps {
userRole: string;
}
// Keys this modal rewrites from its own form state on save. Anything absent from this set is
// carried through untouched from the stored config, so a key only belongs here once the modal
// actually renders a control that can set it.
const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"tiers",
"classifier_type",
@ -41,6 +40,16 @@ const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"return_raw_model_name",
]);
// Managed only when the caller passes the corresponding state. A caller that does not render
// these controls must carry the stored values through untouched instead of dropping them.
const KEYWORD_MATCHING_KEYS = new Set([
"keyword_tier_rules",
"escalation_keywords",
"semantic_keyword_matching",
"embedding_model",
"match_threshold",
]);
const toRecord = (value: unknown): Record<string, unknown> => {
const parsed: unknown = typeof value === "string" ? JSON.parse(value) : value;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
@ -48,19 +57,29 @@ const toRecord = (value: unknown): Record<string, unknown> => {
: {};
};
export interface KeywordMatchingState {
keywordTierRules: KeywordTierRule[];
escalationKeywords: string[];
semanticMatchingEnabled: boolean;
embeddingModel: string | undefined;
matchThreshold: number;
}
export const buildUpdatedComplexityRouterConfig = (
storedConfig: unknown,
value: ComplexityRouterConfigValue,
customTechnicalKeywords?: string[],
keywordMatching?: KeywordMatchingState,
): Record<string, unknown> => {
const preservedConfig = Object.fromEntries(
Object.entries(toRecord(storedConfig)).filter(
([key]) =>
!MANAGED_COMPLEXITY_ROUTER_KEYS.has(key) &&
(customTechnicalKeywords === undefined || key !== "custom_technical_keywords"),
),
);
const isManaged = (key: string): boolean => {
if (MANAGED_COMPLEXITY_ROUTER_KEYS.has(key)) return true;
if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true;
return customTechnicalKeywords !== undefined && key === "custom_technical_keywords";
};
const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key)));
const adaptiveEligible = value.adaptive_eligible ?? "all";
const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : [];
return {
...preservedConfig,
@ -80,6 +99,17 @@ export const buildUpdatedComplexityRouterConfig = (
adaptive_eligible: adaptiveEligible,
}),
...(value.return_raw_model_name && { return_raw_model_name: true }),
...(keywordMatching && {
// Mirrors buildComplexityRouterConfig: rules only when non-empty (the backend rejects
// an empty rule with a 400), escalation keywords always, semantic trio only when on.
...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }),
escalation_keywords: keywordMatching.escalationKeywords.map((k) => k.trim()).filter(Boolean),
...(keywordMatching.semanticMatchingEnabled && {
semantic_keyword_matching: true,
embedding_model: keywordMatching.embeddingModel,
match_threshold: keywordMatching.matchThreshold,
}),
}),
};
};
@ -99,11 +129,16 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
const [routerConfig, setRouterConfig] = useState<any>(null);
const [customTechnicalKeywords, setCustomTechnicalKeywords] = useState<string[]>([]);
const [keywordTierRules, setKeywordTierRules] = useState<KeywordTierRule[]>([]);
const [escalationKeywords, setEscalationKeywords] = useState<string[]>([]);
const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState<boolean>(false);
const [embeddingModel, setEmbeddingModel] = useState<string | undefined>(undefined);
const [matchThreshold, setMatchThreshold] = useState<number>(DEFAULT_MATCH_THRESHOLD);
const [complexityRouterConfig, setComplexityRouterConfig] = useState<ComplexityRouterConfigValue>({
tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "heuristic",
});
const isComplexityRouter = isComplexityRouterModel(modelData);
const isComplexityRouterModel = isComplexityRouter(modelData?.litellm_params);
useEffect(() => {
if (isVisible && modelData) {
@ -140,7 +175,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
const initializeForm = () => {
try {
if (isComplexityRouterModel(modelData)) {
if (isComplexityRouterModel) {
// Parse the complexity_router_config if it exists and is a string
let parsedConfig = modelData.litellm_params?.complexity_router_config || {};
if (typeof parsedConfig === "string") {
@ -165,6 +200,20 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
setCustomTechnicalKeywords(
Array.isArray(parsedConfig.custom_technical_keywords) ? parsedConfig.custom_technical_keywords : [],
);
// Hydrated from the stored config, never from create-form defaults: these keys are now
// rewritten on save, so seeding a default here would inject it into a config that never
// had it.
setKeywordTierRules(hydrateKeywordTierRules(parsedConfig.keyword_tier_rules));
setEscalationKeywords(
Array.isArray(parsedConfig.escalation_keywords)
? parsedConfig.escalation_keywords.filter((k: unknown): k is string => typeof k === "string")
: [],
);
setSemanticMatchingEnabled(parsedConfig.semantic_keyword_matching === true);
setEmbeddingModel(typeof parsedConfig.embedding_model === "string" ? parsedConfig.embedding_model : undefined);
setMatchThreshold(
typeof parsedConfig.match_threshold === "number" ? parsedConfig.match_threshold : DEFAULT_MATCH_THRESHOLD,
);
form.setFieldsValue({
auto_router_name: modelData.model_name,
@ -208,7 +257,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
setLoading(true);
const values = await form.validateFields();
if (isComplexityRouter) {
if (isComplexityRouterModel) {
const { tiers, classifier_type, classifier_llm_config } = complexityRouterConfig;
if (Object.values(tiers).every((models) => models.length === 0)) {
NotificationsManager.fromBackend("Please select at least one model for a complexity tier");
@ -218,6 +267,20 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
NotificationsManager.fromBackend("Please select a classifier model, or switch back to Heuristic");
return;
}
// Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
// semantic_keyword_matching without an embedding model or keyword rules
// (complexity_router/config.py), so without this a save fails as a raw 400 instead of
// an inline message.
// Same guard the create form applies (add_auto_router_tab.tsx). The backend rejects
// semantic_keyword_matching without an embedding model or keyword rules
// (complexity_router/config.py), so without this a save fails as a raw 400 instead of
// an inline message.
const semanticError = getSemanticConfigError({ semanticMatchingEnabled, embeddingModel, keywordTierRules });
if (semanticError) {
NotificationsManager.fromBackend(semanticError);
return;
}
const defaultModel = tiers.MEDIUM[0] || tiers.SIMPLE[0] || tiers.COMPLEX[0] || tiers.REASONING[0];
const updatedLitellmParams = {
@ -226,6 +289,13 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
modelData.litellm_params?.complexity_router_config,
complexityRouterConfig,
customTechnicalKeywords,
{
keywordTierRules,
escalationKeywords,
semanticMatchingEnabled,
embeddingModel,
matchThreshold,
},
),
complexity_router_default_model: defaultModel,
};
@ -327,7 +397,7 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
<TextInput placeholder="e.g., auto_router_1, smart_routing" />
</Form.Item>
{isComplexityRouter ? (
{isComplexityRouterModel ? (
/* Complexity Router Configuration */
<div className="w-full">
<ComplexityRouterConfig
@ -338,6 +408,16 @@ const EditAutoRouterModal: React.FC<EditAutoRouterModalProps> = ({
}}
customTechnicalKeywords={customTechnicalKeywords}
onCustomTechnicalKeywordsChange={setCustomTechnicalKeywords}
keywordTierRules={keywordTierRules}
onKeywordTierRulesChange={setKeywordTierRules}
semanticMatchingEnabled={semanticMatchingEnabled}
onSemanticMatchingEnabledChange={setSemanticMatchingEnabled}
embeddingModel={embeddingModel}
onEmbeddingModelChange={setEmbeddingModel}
matchThreshold={matchThreshold}
onMatchThresholdChange={setMatchThreshold}
escalationKeywords={escalationKeywords}
onEscalationKeywordsChange={setEscalationKeywords}
/>
</div>
) : (

View file

@ -73,6 +73,16 @@ vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({
const collectNavKeys = (): string[] =>
menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)]));
// Every place a page id appears in the nav, as "GROUP" for a top-level item or
// "GROUP > parentKey" for a child.
const placementsOf = (page: string): string[] =>
menuGroups.flatMap((group) => [
...group.items.filter((item) => item.page === page).map(() => group.groupLabel),
...group.items.flatMap((item) =>
(item.children ?? []).filter((child) => child.page === page).map(() => `${group.groupLabel} > ${item.key}`),
),
]);
describe("Sidebar (leftnav)", () => {
const defaultProps = {
setPage: vi.fn(),
@ -129,6 +139,13 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Search Tools")).toBeInTheDocument();
});
});
it("keeps Router Settings as a single Settings child", () => {
// Router Settings is admin-only, so getAvailablePages() filters it out entirely and the
// page_utils duplicate-key guard cannot see it. Walk menuGroups directly, otherwise a
// stray duplicate placement ships silently.
expect(placementsOf("router-settings")).toEqual(["SETTINGS > settings"]);
});
it("has no duplicate keys among all menu items and their children", () => {
// React keys must be unique across the whole nav config, otherwise the
// active-item highlight and group expansion collide.
@ -273,6 +290,10 @@ describe("getBreadcrumb", () => {
expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" });
});
it("resolves router-settings under the Settings section", () => {
expect(getBreadcrumb("router-settings")).toEqual({ section: "Settings", title: "Router Settings" });
});
it("falls back to a prettified title with no section for unknown pages", () => {
expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
});

View file

@ -949,4 +949,89 @@ describe("ModelInfoView", () => {
expect(screen.queryByAltText("zzz-internal logo")).not.toBeInTheDocument();
expect(screen.getByText("z")).toBeInTheDocument();
});
// EditAutoRouterModal only speaks complexity and semantic. Offering it for an adaptive or
// quality router lets a save write auto_router_config onto a row that stores its settings
// elsewhere. These rows stay reachable from Health Status and direct ?model= links even
// though the Models table now excludes auto-routers, so the button itself has to be gated.
describe("Edit Auto Router affordance", () => {
const withRouter = (litellmParams: Record<string, unknown>) => {
mockUseModelsInfo.mockReturnValue({
data: { data: [{ ...defaultModelData, litellm_params: { ...litellmParams } }] },
isLoading: false,
error: null,
});
};
it.each([
["auto_router/adaptive_router", "adaptive"],
["auto_router/quality_router", "quality"],
])("is absent for a %s router", async (model) => {
withRouter({ model });
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByText("GPT-4")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /edit auto router/i })).not.toBeInTheDocument();
});
it("is present for a complexity router, which the modal does understand", async () => {
withRouter({ model: "auto_router/complexity_router", complexity_router_config: { tiers: {} } });
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByRole("button", { name: /edit auto router/i })).toBeInTheDocument();
});
});
// An auto router has no upstream credential, so the credential actions are meaningless for
// every strategy, and the destructive action should name what it actually removes.
describe("auto-router header actions", () => {
const withParams = (litellmParams: Record<string, unknown>) => {
mockUseModelsInfo.mockReturnValue({
data: { data: [{ ...defaultModelData, litellm_params: { ...litellmParams } }] },
isLoading: false,
error: null,
});
};
it.each([
["auto_router/complexity_router"],
["auto_router/adaptive_router"],
["auto_router/quality_router"],
["auto_router/my-semantic"],
])("hides the credential actions and renames delete for %s", async (model) => {
withParams({ model });
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByTestId("delete-model-button")).toHaveTextContent("Delete Auto-Router");
expect(screen.queryByTestId("update-api-key-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("reuse-credentials-button")).not.toBeInTheDocument();
});
it("keeps both credential actions and the Delete Model label for an ordinary model", async () => {
withParams({ model: "gpt-4", api_base: "https://api.openai.com/v1" });
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByTestId("delete-model-button")).toHaveTextContent("Delete Model");
expect(screen.getByTestId("update-api-key-button")).toBeInTheDocument();
expect(screen.getByTestId("reuse-credentials-button")).toBeInTheDocument();
});
it.each([["auto_router/adaptive_router"], ["auto_router/quality_router"]])(
"offers no Test Connection for %s, whose targets it cannot build",
async (model) => {
withParams({ model });
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
await screen.findByTestId("delete-model-button");
expect(screen.queryByTestId("test-connection-button")).not.toBeInTheDocument();
},
);
it("keeps Test Connection for a complexity router", async () => {
withParams({ model: "auto_router/complexity_router", complexity_router_config: { tiers: {} } });
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByTestId("test-connection-button")).toBeInTheDocument();
});
});
});

View file

@ -26,6 +26,14 @@ import { isMaskedSecret, stripMaskedSecrets } from "../utils/maskedSecretUtils";
import { formItemValidateJSON, truncateString } from "../utils/textUtils";
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets";
import { normalizeTierModels } from "./add_model/complexity_router_tiers";
import {
hasAutoRouterEditor,
isAutoRouterDeployment,
isComplexityRouter as isComplexityRouterParams,
} from "./add_model/auto_router_strategies";
import { canModifyModel } from "@/utils/modelPermissions";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import CacheControlSettings from "./add_model/cache_control_settings";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
@ -59,12 +67,6 @@ interface ModelInfoViewProps {
modelAccessGroups: string[] | null;
}
const normalizeTierModels = (value: unknown): string[] => {
if (Array.isArray(value)) return value;
if (typeof value === "string" && value) return [value];
return [];
};
interface ComplexityRouterTierConfig {
tiers?: {
SIMPLE?: unknown;
@ -153,6 +155,7 @@ export default function ModelInfoView({
const { data: rawModelDataResponse, isLoading: isLoadingModel } = useModelsInfo(1, 50, undefined, modelId);
const { data: modelCostMapData } = useModelCostMap();
const { data: modelHubData } = useModelHub();
const { data: teams } = useTeams();
// Transform the model data
const getProviderFromModel = (model: string) => {
@ -175,16 +178,18 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
const canEditModel =
(userRole === "Admin" || modelData?.model_info?.created_by === userID) && modelData?.model_info?.db_model;
const canEditModel = canModifyModel({ userRole, userID }, teams ?? null, {
teamId: modelData?.model_info?.team_id,
isDbModel: modelData?.model_info?.db_model === true,
});
const isAdmin = userRole === "Admin";
const isAutoRouter =
modelData?.litellm_params?.auto_router_config != null ||
modelData?.litellm_params?.complexity_router_config != null ||
modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router");
const isComplexityRouter =
modelData?.litellm_params?.complexity_router_config != null ||
modelData?.litellm_params?.model?.startsWith("auto_router/complexity_router");
// Editor-aware on purpose: an adaptive or quality router must not offer Edit Auto Router.
const isAutoRouterModel = hasAutoRouterEditor(modelData?.litellm_params);
// Broader than the editor check: adaptive and quality routers equally have no upstream
// credential, so the credential actions are meaningless for every auto-router strategy.
const isAnyAutoRouter = isAutoRouterDeployment(modelData?.litellm_params);
const deleteLabel = isAnyAutoRouter ? "Delete Auto-Router" : "Delete Model";
const isComplexityRouterModel = isComplexityRouterParams(modelData?.litellm_params);
const usingExistingCredential =
modelData?.litellm_params?.litellm_credential_name != null &&
@ -492,7 +497,7 @@ export default function ModelInfoView({
const handleTestConnection = async () => {
if (!accessToken) return;
if (isComplexityRouter) {
if (isComplexityRouterModel) {
const targets = buildComplexityRouterTestTargets(localModelData ?? modelData);
if (targets.length === 0) {
NotificationsManager.warning("No complexity tiers are configured yet, so there is nothing to test.");
@ -604,7 +609,7 @@ export default function ModelInfoView({
</div>
</div>
<div className="flex gap-2">
{(!isAutoRouter || isComplexityRouter) && (
{(!isAnyAutoRouter || isComplexityRouterModel) && (
<Button
icon={<RefreshIcon className="h-4 w-4" />}
onClick={handleTestConnection}
@ -615,25 +620,29 @@ export default function ModelInfoView({
</Button>
)}
<Button
icon={<KeyIcon className="h-4 w-4" />}
onClick={() => setIsUpdateCredentialsModalOpen(true)}
className="flex items-center"
disabled={!canEditModel}
data-testid="update-api-key-button"
>
Update API Key
</Button>
{!isAnyAutoRouter && (
<>
<Button
icon={<KeyIcon className="h-4 w-4" />}
onClick={() => setIsUpdateCredentialsModalOpen(true)}
className="flex items-center"
disabled={!canEditModel}
data-testid="update-api-key-button"
>
Update API Key
</Button>
<Button
icon={<KeyIcon className="h-4 w-4" />}
onClick={() => setIsCredentialModalOpen(true)}
className="flex items-center"
disabled={!isAdmin}
data-testid="reuse-credentials-button"
>
Re-use Credentials
</Button>
<Button
icon={<KeyIcon className="h-4 w-4" />}
onClick={() => setIsCredentialModalOpen(true)}
className="flex items-center"
disabled={!isAdmin}
data-testid="reuse-credentials-button"
>
Re-use Credentials
</Button>
</>
)}
<Button
danger
icon={<TrashIcon className="h-4 w-4" />}
@ -642,7 +651,7 @@ export default function ModelInfoView({
disabled={!canEditModel}
data-testid="delete-model-button"
>
Delete Model
{deleteLabel}
</Button>
</div>
</div>
@ -721,7 +730,7 @@ export default function ModelInfoView({
<div className="flex justify-between items-center mb-4">
<Title>Model Settings</Title>
<div className="flex gap-2">
{isAutoRouter && canEditModel && !isEditing && (
{isAutoRouterModel && canEditModel && !isEditing && (
<TremorButton onClick={() => setIsAutoRouterModalOpen(true)} className="flex items-center">
Edit Auto Router
</TremorButton>
@ -1414,9 +1423,9 @@ export default function ModelInfoView({
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Model"
title={deleteLabel}
alertMessage="This action cannot be undone."
message="Are you sure you want to delete this model?"
message={`Are you sure you want to delete this ${isAnyAutoRouter ? "auto-router" : "model"}?`}
resourceInformationTitle="Model Information"
resourceInformation={[
{

View file

@ -1570,6 +1570,7 @@ export const modelInfoCall = async (
teamId?: string,
sortBy?: string,
sortOrder?: string,
excludeAutoRouters?: boolean,
) => {
/**
* Get all models on proxy
@ -1595,6 +1596,9 @@ export const modelInfoCall = async (
if (sortOrder && sortOrder.trim()) {
params.append("sortOrder", sortOrder.trim());
}
if (excludeAutoRouters) {
params.append("exclude_auto_routers", "true");
}
if (params.toString()) {
url += `?${params.toString()}`;
}

View file

@ -58089,6 +58089,8 @@ export interface operations {
sortBy?: string | null;
/** @description Sort order. Options: asc, desc */
sortOrder?: string | null;
/** @description Omit auto-router deployments (litellm model prefixed `auto_router/`). They select among deployments rather than being deployments themselves, so a caller rendering a deployment list can leave them out. Defaults to false, so existing callers are unaffected */
exclude_auto_routers?: boolean | null;
};
header?: never;
path?: never;

View file

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { Team } from "@/components/networking";
import { canModifyModel, modelCreationScope } from "./modelPermissions";
const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] =>
[{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[];
const PROXY_ADMIN = { userRole: "Admin", userID: "u-admin" };
const TEAM_ADMIN = { userRole: "Internal User", userID: "u-team-admin" };
const MEMBER = { userRole: "Internal User", userID: "u-member" };
const noLimits = { disabledForInternalUsers: false };
describe("modelCreationScope", () => {
it("lets a proxy admin create without naming a team", () => {
expect(modelCreationScope(PROXY_ADMIN, { teams: null, ...noLimits })).toBe("unscoped-ok");
});
// Live-verified: POST /model/new from a team admin 403s without model_info.team_id and
// returns 200 with it, so the form must make the team mandatory rather than optional.
it("requires a team admin to name a team", () => {
expect(modelCreationScope(TEAM_ADMIN, { teams: teamWhere("u-team-admin", "admin"), ...noLimits })).toBe(
"team-required",
);
});
it("forbids a plain team member", () => {
expect(modelCreationScope(MEMBER, { teams: teamWhere("u-member", "user"), ...noLimits })).toBe("forbidden");
});
// The admin setting is scoped to internal users and must never lock out a proxy admin.
it("honours the internal-user kill switch without touching proxy admins", () => {
const limits = { teams: teamWhere("u-team-admin", "admin"), disabledForInternalUsers: true };
expect(modelCreationScope(TEAM_ADMIN, limits)).toBe("forbidden");
expect(modelCreationScope(PROXY_ADMIN, limits)).toBe("unscoped-ok");
});
// org_admin and Admin Viewer are in all_admin_roles but are not PROXY_ADMIN to the API, so
// an unscoped create from them 403s. Treating them as admins here is what let a form submit
// a payload the backend always rejected.
it("does not treat an org admin as able to create unscoped", () => {
const orgAdmin = { userRole: "org_admin", userID: "u-org" };
expect(modelCreationScope(orgAdmin, { teams: teamWhere("u-org", "admin"), ...noLimits })).toBe("team-required");
});
});
describe("canModifyModel", () => {
const teamRow = { teamId: "team-1", isDbModel: true };
// config.yaml rows: PATCH /model/{id}/update 404s and POST /model/delete 400s for everyone.
it("refuses a config-defined row even to a proxy admin", () => {
expect(canModifyModel(PROXY_ADMIN, null, { teamId: "team-1", isDbModel: false })).toBe(false);
});
it("lets a proxy admin act on any DB row", () => {
expect(canModifyModel(PROXY_ADMIN, null, teamRow)).toBe(true);
});
// The regression this whole owner exists for. Live-verified: a model created by the proxy
// admin (created_by=default_user_id) was PATCHed and DELETEd 200 by a team admin who did
// not create it. Authorizing on created_by hid controls the API accepts.
it("lets a team admin act on their team's row they did not create", () => {
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), teamRow)).toBe(true);
});
it("refuses a plain member of the owning team", () => {
expect(canModifyModel(MEMBER, teamWhere("u-member", "user"), teamRow)).toBe(false);
});
it("refuses a team admin of a different team", () => {
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin", "other-team"), teamRow)).toBe(false);
});
// Unscoped rows can only have been created by a proxy admin, and only one can edit them.
it("refuses a team admin on an unscoped row", () => {
expect(canModifyModel(TEAM_ADMIN, teamWhere("u-team-admin", "admin"), { teamId: null, isDbModel: true })).toBe(
false,
);
});
it("does not treat two absent identities as a match", () => {
expect(canModifyModel({ userRole: "Internal User", userID: null }, null, teamRow)).toBe(false);
});
});

View file

@ -0,0 +1,80 @@
import { Team } from "@/components/networking";
import { isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam } from "./roles";
/**
* The dashboard's mirror of ModelManagementAuthChecks in
* litellm/proxy/management_endpoints/model_management_endpoints.py.
*
* Both questions below are answered there by exactly two inputs: the caller's role, and
* whether the caller admins the team named in `model_info.team_id`. `created_by` is written
* at creation and never read by an auth check, so it is deliberately absent here; gating on
* it hid controls from team admins the API accepts, and showed controls to former team admins
* the API rejects.
*/
export interface ModelActor {
userRole: string | null;
userID: string | null;
}
/** How this actor must scope a deployment they create, or that they may not create one. */
export type ModelWriteScope = "forbidden" | "unscoped-ok" | "team-required";
export interface ModelCreationLimits {
teams: Team[] | null;
/** The admin setting that withdraws model creation from internal users. */
disabledForInternalUsers: boolean;
}
const isTeamAdminOf = (teams: Team[] | null, userID: string, teamId: string): boolean => {
const team = teams?.find((candidate) => candidate.team_id === teamId);
return team != null && isUserTeamAdminForSingleTeam(team.members_with_roles, userID);
};
/**
* POST /model/new takes a proxy admin unconditionally, or a team admin whose payload names a
* team; an unscoped create from anyone else is a 403. Returning the requirement rather than a
* pair of booleans keeps "may not create" and "may create unscoped" from being confused.
*/
export const modelCreationScope = (
{ userRole, userID }: ModelActor,
{ teams, disabledForInternalUsers }: ModelCreationLimits,
): ModelWriteScope => {
if (userRole != null && isProxyAdminRole(userRole)) {
return "unscoped-ok";
}
if (disabledForInternalUsers) {
return "forbidden";
}
if (userID != null && isUserTeamAdminForAnyTeam(teams, userID)) {
return "team-required";
}
return "forbidden";
};
export const canCreateModels = (actor: ModelActor, limits: ModelCreationLimits): boolean =>
modelCreationScope(actor, limits) !== "forbidden";
export interface ModelRowOrigin {
teamId: string | null | undefined;
/** False for config.yaml rows, which update and delete both refuse whoever asks. */
isDbModel: boolean;
}
/** May this actor edit or delete this specific deployment? */
export const canModifyModel = (
{ userRole, userID }: ModelActor,
teams: Team[] | null,
{ teamId, isDbModel }: ModelRowOrigin,
): boolean => {
if (!isDbModel) {
return false;
}
if (userRole != null && isProxyAdminRole(userRole)) {
return true;
}
if (userID == null || teamId == null) {
return false;
}
return isTeamAdminOf(teams, userID, teamId);
};