From fec7f5f2461ab610858f870b65bae5108b3844df Mon Sep 17 00:00:00 2001
From: Tin Chi Lo
Date: Tue, 28 Jul 2026 15:20:07 -0700
Subject: [PATCH 1/2] feat(ui): give auto-routers their own tab on Models +
Endpoints
Auto-routers had no home and no list. The create form was mounted in two unrelated places,
inside Models + Endpoints > Add Model and again under Cost Optimization, and neither showed
which auto routers already existed; seeing or editing one meant finding its row in the models
table and drilling in. They now get a dedicated Auto-Routers tab beside All Models, listing
every auto_router/* deployment with create, edit and delete in one place, and both former
entry points are removed.
Creating opens in a shadcn dialog rather than swapping the whole panel out, so the list stays
on screen behind it; the dialog caps its height and scrolls, since the complexity form is long.
The form's own heading goes with it, the dialog header owning that now.
An auto router is a routing construct rather than a deployment, so it also comes off the All
Models table. That table pages server-side off total_count, so a client-side filter would page
over a total including rows it never renders; /v2/model/info therefore gains
exclude_auto_routers (default false, so every existing caller is unaffected) and the filter
runs before the count. /v1/models is untouched, so clients still see auto-routers as models.
Clicking a router opens the same `?model=` drill-in the All Models table uses, so it lands in
ModelInfoView with the full Model Settings, Edit Settings, Edit Auto Router and Delete. An
earlier revision had a bespoke detail page here, which was a partial reimplementation of that
view and showed the router's type twice, once as a Type pill and again as a "Routing strategy"
field saying the same thing. Both are gone.
The auto-router list is keyed under the same `models/list` namespace as the models table
rather than a private one. It reads the same /v2/model/info data, and six call sites across
the app already invalidate ["models","list"] after a write; a separate key meant an edit made
through ModelInfoView left the tab stale until a full reload, and every future writer would
have had to remember a second key.
An auto router has no upstream credential, so its detail header drops Update API Key and
Re-use Credentials, and the destructive action names what it removes rather than saying model.
Test Connection was gated on the editor-aware predicate, which let adaptive and quality routers
through to a check that builds its targets from complexity config they do not have; it now
gates on the deployment predicate.
The edit modal also applies the semantic-matching guard the create form has. It renders those
controls now, and the backend raises on semantic_keyword_matching without an embedding model or
keyword rules, so skipping the shared validator turned an inline message into a raw 400.
Whether a row is writable has two independent axes and the dashboard needs both. STRATEGY:
there are four auto_router/* kinds and only complexity and semantic have a form here, so
adaptive and quality must not be handed an editor that would write auto_router_config onto a
deployment storing its settings elsewhere. ORIGIN: a config.yaml row reports db_model false and
the API refuses it whatever its strategy (PATCH /model/{id}/update 404s, POST /model/delete
400s). Capability is derived per capability rather than as one editable flag, because the
constraints differ: editing needs an editor, deleting removes a row by id and never reads its
config, so a DB-created adaptive router stays deletable. Both axes live in
add_model/auto_router_strategies.ts as a declarative table, one record per strategy, so a fifth
strategy is a table row rather than another branch. That also retired four copies of "is this a
complexity router", one of which was written twice in a row in model_info_view.
Creation narrows to the complexity router, which the UI calls Auto-Router v2; the semantic
option was already badged "to be deprecated" in the picker, so the picker goes away along with
the semantic submit path and its validation helper. Existing semantic routers stay editable.
The edit modal mounted ComplexityRouterConfig without the keyword, escalation and
semantic-matching handlers, so those sections never rendered and could only be set at create
time. It now hydrates them from the stored config, and the five keys become managed only when a
caller supplies that state, so a caller rendering no such control still carries them through. A
component-level round-trip test covers it: a payload-builder test cannot see a hydration bug.
A complexity tier is str | list[str] on the backend, and the UI carried three readers of that
rule, one of which dropped a pinned string. They collapse into one owner,
add_model/complexity_router_tiers.ts.
---
litellm/proxy/proxy_server.py | 29 ++
.../proxy_server/test_routes_model_info.py | 146 ++++++++
ui/litellm-dashboard/eslint-suppressions.json | 27 +-
.../_components/AutorouterTab.tsx | 28 --
.../CostOptimizationView.activity.test.tsx | 1 -
.../_components/CostOptimizationView.test.tsx | 7 +-
.../_components/CostOptimizationView.tsx | 9 +-
.../hooks/models/useModels.test.ts | 27 ++
.../app/(dashboard)/hooks/models/useModels.ts | 92 ++++-
.../components/AllModelsTab.tsx | 3 +
.../AutoRouters/AutoRoutersPanel.test.tsx | 252 ++++++++++++++
.../AutoRouters/AutoRoutersPanel.tsx | 117 +++++++
.../AutoRouters/AutoRoutersTable.tsx | 68 ++++
.../AutoRouters/AutoRoutersTableColumns.tsx | 173 ++++++++++
.../AutoRouters/autoRouterRows.test.ts | 196 +++++++++++
.../components/AutoRouters/autoRouterRows.ts | 103 ++++++
.../components/AutoRouters/fitPills.test.ts | 43 +++
.../components/AutoRouters/fitPills.ts | 52 +++
.../models-and-endpoints/page.test.tsx | 31 ++
.../(dashboard)/models-and-endpoints/page.tsx | 22 +-
.../panels/AddModelPanel.tsx | 8 +-
.../panels/AutoRoutersTabPanel.tsx | 23 ++
.../_components/general_settings.test.tsx | 17 +-
.../_components/general_settings.tsx | 152 +++++----
.../add_model/AddModelForm.test.tsx | 13 +
.../add_model/add_auto_router_tab.test.tsx | 6 +-
.../add_model/add_auto_router_tab.tsx | 197 ++---------
.../add_model/add_model_tab.test.tsx | 318 ------------------
.../components/add_model/add_model_tab.tsx | 98 ------
.../add_model/auto_router_strategies.ts | 143 ++++++++
.../build_complexity_router_config.ts | 8 +-
.../build_semantic_router_validation.test.ts | 67 ----
.../build_semantic_router_validation.ts | 29 --
.../add_model/complexity_router_keywords.ts | 40 +++
.../add_model/complexity_router_tiers.test.ts | 30 ++
.../add_model/complexity_router_tiers.ts | 14 +
...d_updated_complexity_router_config.test.ts | 88 +++++
.../edit_auto_router_modal.test.tsx | 121 +++++++
.../edit_auto_router_modal.tsx | 122 +++++--
.../src/components/leftnav.test.tsx | 21 ++
.../src/components/model_info_view.test.tsx | 85 +++++
.../src/components/model_info_view.tsx | 78 +++--
.../src/components/networking.tsx | 4 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +
44 files changed, 2198 insertions(+), 912 deletions(-)
delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts
create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx
delete mode 100644 ui/litellm-dashboard/src/components/add_model/add_model_tab.test.tsx
delete mode 100644 ui/litellm-dashboard/src/components/add_model/add_model_tab.tsx
create mode 100644 ui/litellm-dashboard/src/components/add_model/auto_router_strategies.ts
delete mode 100644 ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.test.ts
delete mode 100644 ui/litellm-dashboard/src/components/add_model/build_semantic_router_validation.ts
create mode 100644 ui/litellm-dashboard/src/components/add_model/complexity_router_keywords.ts
create mode 100644 ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts
create mode 100644 ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts
create mode 100644 ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts
create mode 100644 ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 70484eb1e4e..bf010c7f249 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -11773,6 +11773,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,
@@ -12067,6 +12083,14 @@ 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 are routing constructs rather than deployments, and are managed on the "
+ "Router Settings page. Defaults to false, so existing callers are unaffected"
+ ),
+ ),
):
"""
Paginated model metadata for proxy deployments (pricing, provider, team access).
@@ -12234,6 +12258,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)
diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py
index 00c3c5b1e74..b0e8a85d3fa 100644
--- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py
+++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py
@@ -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"]]
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 7686cc05fa6..c0d24d3a1e6 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -210,11 +210,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 +1691,7 @@
"count": 1
},
"no-restricted-imports": {
- "count": 3
+ "count": 2
},
"prefer-const": {
"count": 2
@@ -2550,17 +2545,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 +2558,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
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx
deleted file mode 100644
index 3474036528a..00000000000
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx
+++ /dev/null
@@ -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 = ({ accessToken, userRole }) => {
- const [form] = Form.useForm();
-
- if (!accessToken) {
- return null;
- }
-
- return (
-
- 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 on the Router Settings page
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts
index f83ebd2622a..f04c4b7bfcd 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts
@@ -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);
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
index ad5e3c91ec3..88c4836f112 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
@@ -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({
@@ -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,29 @@ 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;
+ } | null;
+}
+
export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean =>
Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX));
@@ -80,11 +118,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 => {
+): Promise => {
const firstPage: PaginatedModelInfoResponse = await modelInfoCall(
accessToken,
userId,
@@ -100,18 +141,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 => {
const { accessToken, userId, userRole } = useAuthorized();
- const { data } = useQuery>({
- queryKey: autoRouterKeys.list({
- filters: {
- ...(userId && { userId }),
- ...(userRole && { userRole }),
- },
- }),
+ const { data } = useQuery>({
+ queryKey: autoRouterListKey(userId, userRole),
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
enabled: Boolean(accessToken && userId && userRole),
select: selectAutoRouterModelGroups,
@@ -119,6 +170,23 @@ export const useAutoRouterModelGroups = (): ReadonlySet => {
return data ?? NO_AUTO_ROUTERS;
};
+export const useAutoRouters = (): UseQueryResult => {
+ const { accessToken, userId, userRole } = useAuthorized();
+ return useQuery({
+ queryKey: autoRouterListKey(userId, userRole),
+ queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
+ enabled: Boolean(accessToken && userId && userRole),
+ select: selectAutoRouterDeployments,
+ });
+};
+
+export const useInvalidateAutoRouters = (): (() => Promise) => {
+ const queryClient = useQueryClient();
+ return async () => {
+ await queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
+ };
+};
+
export const useModelHub = () => {
const { accessToken } = useAuthorized();
return useQuery({
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
index 1dc7736d5ac..1a65109eac8 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
@@ -104,6 +104,9 @@ const AllModelsTab = ({
teamIdForQuery,
sortBy,
sortOrder,
+ // Auto-routers are routing constructs, not deployments; they are listed and managed on
+ // the Router Settings page. Excluded server-side so total_count stays honest.
+ true,
);
const isLoading = isLoadingModelsInfo || isLoadingModelCostMap;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
new file mode 100644
index 00000000000..7d026f067fb
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
@@ -0,0 +1,252 @@
+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 } } }) => (
+
+ ),
+}));
+
+vi.mock("@/components/add_model/add_auto_router_tab", () => ({
+ __esModule: true,
+ default: ({ handleOk }: { handleOk: () => void }) => (
+
+ ),
+}));
+
+// 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();
+
+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();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
new file mode 100644
index 00000000000..3fbce1bd4a0
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
@@ -0,0 +1,117 @@
+"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 { AutoRoutersTable } from "./AutoRoutersTable";
+import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
+
+interface AutoRoutersPanelProps {
+ accessToken: string;
+ userRole: string;
+ /** Owned by the page, which knows whether the caller may write. */
+ canModify: boolean;
+}
+
+export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoutersPanelProps) {
+ 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(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const routers = useMemo(() => toAutoRouterRows(deployments ?? []), [deployments]);
+
+ 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 (
+
+
+
+
Auto routers
+
+ 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.
+
+ {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."}
+
@@ -721,7 +725,7 @@ export default function ModelInfoView({
Model Settings
- {isAutoRouter && canEditModel && !isEditing && (
+ {isAutoRouterModel && canEditModel && !isEditing && (
setIsAutoRouterModalOpen(true)} className="flex items-center">
Edit Auto Router
@@ -1414,9 +1418,9 @@ export default function ModelInfoView({
{
/**
* 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()}`;
}
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index ed975c6be0a..121cd79eccb 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -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 are routing constructs rather than deployments, and are managed on the Router Settings page. Defaults to false, so existing callers are unaffected */
+ exclude_auto_routers?: boolean | null;
};
header?: never;
path?: never;
From 516953b073abbbeadfa0f6be635cf1e87e65a9a4 Mon Sep 17 00:00:00 2001
From: Tin Chi Lo
Date: Thu, 30 Jul 2026 00:18:45 -0700
Subject: [PATCH 2/2] feat(ui): let team admins create auto-routers; authorize
models by team, not created_by
The Auto-Routers tab was proxy-admin only, while Add Model on the same page already
admits team admins. The asymmetry was not a policy decision; the auto-router create form
simply never mounted a team selector, so a team admin's submit was unscoped and POST
/model/new rejects an unscoped create from any non-proxy-admin. Mounting the shared
TeamDropdown closes it, and the tab now takes the same audience as its sibling.
Fixing that surfaced a second, larger problem. The dashboard decided who may edit or
delete a deployment with `(userRole === "Admin" || created_by === userID) && db_model`,
but `created_by` is written at creation and never read by any backend auth check. The API
authorizes on team-admin membership of model_info.team_id, so the dashboard was wrong in
both directions: it hid controls from team admins the API accepts, and offered them to
former team admins the API rejects. Verified against a live proxy; a model created by the
proxy admin was PATCHed and DELETEd 200 by a team admin who did not create it, while the
same key got 403 on another team's row and on an unscoped row.
Both questions now have one owner in utils/modelPermissions.ts, deliberately shaped as a
mirror of ModelManagementAuthChecks. Creation returns a tagged union rather than a pair of
booleans, so "may not create" and "may create unscoped" cannot be confused, and the five
places that had each invented their own spelling (the models page, the auto-routers tab
and panel, the auto-router form, and both branches of AddModelForm) call it instead.
Row affordances are now per row rather than per tab, because opening the tab to team
admins puts routers they cannot act on in the same list.
Note for reviewers: collapsing AddModelForm onto the shared owner changes behaviour for
org_admin and Admin Viewer who also admin a team. They previously got the optional team
selector, because all_admin_roles counts them as admins, and could submit an unscoped
create that the API always 403s; they now get the required selector.
Also corrects stale copy left by the auto-router move. The exclude_auto_routers API
description named a dashboard page, which went stale inside a single PR; it now describes
the concept so it cannot drift with the UI again.
The eslint-suppressions prune includes one entry for caching/_components/cache_dashboard.tsx,
which this branch does not touch. Its baseline was already stale; the gate measures the whole
tree, so it could not be left behind.
---
litellm/proxy/proxy_server.py | 5 +-
ui/litellm-dashboard/eslint-suppressions.json | 7 +-
.../_components/CostOptimizationView.tsx | 2 +-
.../app/(dashboard)/hooks/models/useModels.ts | 1 +
.../components/AllModelsTab.tsx | 4 +-
.../AutoRouters/AutoRoutersPanel.test.tsx | 10 ++-
.../AutoRouters/AutoRoutersPanel.tsx | 27 ++++--
.../AutoRouters/autoRouterRows.test.ts | 82 ++++++++++++++++--
.../components/AutoRouters/autoRouterRows.ts | 30 +++++--
.../(dashboard)/models-and-endpoints/page.tsx | 22 +++--
.../panels/AutoRoutersTabPanel.tsx | 27 ++++--
.../src/components/add_model/AddModelForm.tsx | 9 +-
.../add_model/add_auto_router_tab.test.tsx | 80 ++++++++++++++++-
.../add_model/add_auto_router_tab.tsx | 31 ++++++-
.../src/components/model_info_view.tsx | 9 +-
ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +-
.../src/utils/modelPermissions.test.ts | 85 +++++++++++++++++++
.../src/utils/modelPermissions.ts | 80 +++++++++++++++++
18 files changed, 459 insertions(+), 54 deletions(-)
create mode 100644 ui/litellm-dashboard/src/utils/modelPermissions.test.ts
create mode 100644 ui/litellm-dashboard/src/utils/modelPermissions.ts
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index bf010c7f249..c6d9e28226f 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -12087,8 +12087,9 @@ async def model_info_v2(
False,
description=(
"Omit auto-router deployments (litellm model prefixed `auto_router/`). "
- "They are routing constructs rather than deployments, and are managed on the "
- "Router Settings page. Defaults to false, so existing callers are unaffected"
+ "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"
),
),
):
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index c0d24d3a1e6..6819b2851f5 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -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": {
@@ -3320,7 +3317,7 @@
"count": 5
},
"no-restricted-syntax": {
- "count": 153
+ "count": 152
},
"prefer-const": {
"count": 32
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
index 9c2d0b20b56..f6593e80999 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx
@@ -45,7 +45,7 @@ const CostOptimizationView: React.FC = ({ accessToken
Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers
- live on the Router Settings page
+ live under Models + Endpoints, on the Auto-Routers tab
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
index 88c4836f112..52459d69b9a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts
@@ -104,6 +104,7 @@ export interface AutoRouterDeployment extends AutoRouterCandidateDeployment {
created_at?: string | null;
updated_at?: string | null;
team_id?: string | null;
+ created_by?: string | null;
} | null;
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
index 1a65109eac8..1d206f81030 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx
@@ -104,8 +104,8 @@ const AllModelsTab = ({
teamIdForQuery,
sortBy,
sortOrder,
- // Auto-routers are routing constructs, not deployments; they are listed and managed on
- // the Router Settings page. Excluded server-side so total_count stays honest.
+ // 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;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
index 7d026f067fb..9ec551bc227 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.test.tsx
@@ -108,7 +108,15 @@ const mockDeploymentsPage = () => {
};
const renderPanel = (canModify = true) =>
- renderWithProviders();
+ renderWithProviders(
+ ,
+ );
describe("AutoRoutersPanel", () => {
beforeEach(() => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
index 3fbce1bd4a0..f27c1e1c44a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
@@ -11,6 +11,8 @@ 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";
@@ -18,11 +20,14 @@ import { AutoRouterRow, toAutoRouterRows } from "./autoRouterRows";
interface AutoRoutersPanelProps {
accessToken: string;
userRole: string;
- /** Owned by the page, which knows whether the caller may write. */
- canModify: boolean;
+ 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, canModify }: AutoRoutersPanelProps) {
+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
@@ -33,7 +38,10 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
const [deletingRouter, setDeletingRouter] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
- const routers = useMemo(() => toAutoRouterRows(deployments ?? []), [deployments]);
+ const routers = useMemo(
+ () => toAutoRouterRows(deployments ?? [], { userRole, userID }, teams),
+ [deployments, userRole, userID, teams],
+ );
const handleCreated = () => {
setIsCreating(false);
@@ -65,7 +73,7 @@ export function AutoRoutersPanel({ accessToken, userRole, canModify }: AutoRoute
so clients keep using a single model name.