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 ( -
- form.resetFields()} accessToken={accessToken} userRole={userRole} /> -
- ); -}; - -export default AutorouterTab; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx index 363525c48af..ca7adf07941 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -27,7 +27,6 @@ vi.mock("@/app/(dashboard)/router-settings/_components/general_settings", () => })); vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
})); -vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () =>
})); import CostOptimizationView from "./CostOptimizationView"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 46aa23fcfc0..42dc7719144 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
})); vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
})); -vi.mock("./AutorouterTab", () => ({ __esModule: true, default: () =>
})); vi.mock("./PromptCachingTab", () => ({ __esModule: true, default: () =>
})); import CostOptimizationView from "./CostOptimizationView"; @@ -11,13 +10,13 @@ import CostOptimizationView from "./CostOptimizationView"; const renderView = () => render(); 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", () => { 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 3bab6afee57..9c2d0b20b56 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 @@ -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 = ({ accessToken label: "Prompt Compression", children: , }, - { - key: "autorouter", - label: "Autorouter", - children: , - }, { key: "caching", label: "Prompt Caching", @@ -50,7 +44,8 @@ const CostOptimizationView: React.FC = ({ accessToken

Cost Optimization

- 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 } } }) => ( +
+ edit:{modelData.model_name}:{modelData.model_info?.id} +
+ ), +})); + +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 && ( + + )} +
+ + openModel(row.id)} + onDeleteClick={setDeletingRouter} + /> + + + {/* The form is long, so the dialog caps its height and scrolls its body rather than + growing past the viewport. */} + + + Add Auto Router + + Routes each request to a model by classifying its complexity. Called like any other model, so clients keep + using a single model name. + + + + + + + {deletingRouter && ( + setDeletingRouter(null)} + onOk={handleConfirmDelete} + confirmLoading={isDeleting} + /> + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx new file mode 100644 index 00000000000..943388f8535 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx @@ -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 ( +
+
+ +
+
No auto routers yet
+
+ {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."} +
+
+ ); +} + +export function AutoRoutersTable({ + routers, + isLoading, + canModify, + onRouterClick, + onDeleteClick, +}: AutoRoutersTableProps) { + const [sorting, setSorting] = useState([]); + + const columns = useMemo( + () => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }), + [canModify, onRouterClick, onDeleteClick], + ); + + return ( + router.id} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + paginationMode="client" + pageSizeOptions={PAGE_SIZE_OPTIONS} + isLoading={isLoading} + loadingMessage="Loading auto routers…" + noDataMessage={} + size="compact" + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx new file mode 100644 index 00000000000..995ba634c34 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx @@ -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 ( + + {row.typeLabel} + + ); +} + +function TargetsCell({ targets }: { targets: string[] }) { + const containerRef = useRef(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 -; + } + + return ( +
+ {visible.map((target) => ( + + {target} + + ))} + {overflow > 0 && ( + + +{overflow} + + )} +
+ ); +} + +function AutoRouterRowActions({ + row, + onDeleteClick, +}: { + row: AutoRouterRow; + onDeleteClick: (row: AutoRouterRow) => void; +}) { + return ( + + + + + + onDeleteClick(row)} + > + + Delete auto router + + + + ); +} + +interface AutoRoutersTableColumnsDeps { + canModify: boolean; + onRouterClick: (row: AutoRouterRow) => void; + onDeleteClick: (row: AutoRouterRow) => void; +} + +export const getAutoRoutersTableColumns = ({ + canModify, + onRouterClick, + onDeleteClick, +}: AutoRoutersTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Name" }, + header: ({ column }) => , + size: 260, + enableSorting: true, + cell: ({ row }) => onRouterClick(row.original)} />, + }, + { + id: "kind", + accessorKey: "kind", + meta: { title: "Type" }, + header: "Type", + size: 180, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "targets", + meta: { title: "Routes to" }, + header: "Routes to", + size: 320, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "defaultModel", + accessorKey: "defaultModel", + meta: { title: "Default model" }, + header: "Default model", + size: 200, + enableSorting: false, + cell: ({ row }) => + row.original.defaultModel ? ( + + {row.original.defaultModel} + + ) : ( + - + ), + }, + { + id: "createdAt", + accessorKey: "createdAt", + meta: { title: "Created" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + sortingFn: "datetime", + cell: ({ row }) => , + }, + ...(canModify + ? [ + { + id: "actions", + meta: { title: "" }, + header: "", + size: 60, + enableSorting: false, + cell: ({ row }) => + row.original.canDelete ? : null, + } satisfies ColumnDef, + ] + : []), +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts new file mode 100644 index 00000000000..924391d16fb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { autoRouterStrategy, isComplexityRouter } from "@/components/add_model/auto_router_strategies"; +import { toAutoRouterRow, toAutoRouterRows } from "./autoRouterRows"; + +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); + + 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); + + 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, + ); + + 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, + ); + + 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, + ); + + 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" } }, + ]); + + 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, + ); + + 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, + ); + + 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); + + 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); + expect(row.canEdit).toBe(false); + expect(row.canDelete).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts new file mode 100644 index 00000000000..4817711f469 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/autoRouterRows.ts @@ -0,0 +1,103 @@ +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"; + +export type { AutoRouterKind }; + +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; + /** Delete only needs an API-created row; removing by id never reads the config. */ + 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 => { + const parsed: unknown = typeof value === "string" ? safeParse(value) : value; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : {}; +}; + +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 => + 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): Presentation => ({ + typeLabel: label, + targets: asStringArray(config.available_models), +}); + +/** How each strategy renders itself, given its own config object. */ +const PRESENTERS: Record) => 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): 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); + + return { + id: info.id ?? `${name}-${index}`, + name, + kind: strategy.kind, + canEdit, + canDelete, + 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[]): AutoRouterRow[] => + deployments.map(toAutoRouterRow); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts new file mode 100644 index 00000000000..9d702810c35 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.test.ts @@ -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 }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts new file mode 100644 index 00000000000..fba116af0fd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/fitPills.ts @@ -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 }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index b9dd09d5a71..222a2e04117 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -7,6 +7,7 @@ import ModelsAndEndpointsPage from "./page"; vi.mock("./panels/AllModelsPanel", () => ({ default: () =>
})); vi.mock("./panels/AddModelPanel", () => ({ default: () =>
})); +vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () =>
})); vi.mock("./panels/LlmCredentialsPanel", () => ({ default: () =>
})); vi.mock("./panels/PassThroughPanel", () => ({ default: () =>
})); vi.mock("./panels/HealthStatusPanel", () => ({ default: () =>
})); @@ -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(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index cb173367459..b7b16caf2ad 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -8,12 +8,14 @@ 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 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 +26,7 @@ import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceD type ModelTabSlug = | "add" + | "auto-routers" | "llm-credentials" | "pass-through" | "health" @@ -35,6 +38,7 @@ const BASE_TAB_KEY = "all-models"; const TAB_LABELS: Record = { add: "Add Model", + "auto-routers": "Auto-Routers", "llm-credentials": "LLM Credentials", "pass-through": "Pass-Through Endpoints", health: "Health Status", @@ -47,6 +51,8 @@ const renderPanel = (key: string) => { switch (key) { case BASE_TAB_KEY: return ; + case "auto-routers": + return ; case "add": return ; case "llm-credentials": @@ -89,6 +95,7 @@ export default function ModelsAndEndpointsPage() { () => [ "", ...(shouldHideAddModelTab ? [] : (["add"] as const)), + ...(isAdmin ? (["auto-routers"] as const) : []), ...(isAdmin ? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const) : []), @@ -97,11 +104,24 @@ export default function ModelsAndEndpointsPage() { ); 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 ( + + {TAB_LABELS[slug]} + + ); + } + 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, }; }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 26dcc60d717..4d7bcc921af 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -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 ( - ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx new file mode 100644 index 00000000000..8b620e22bf9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.tsx @@ -0,0 +1,23 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { isProxyAdminRole } from "@/utils/roles"; + +import { AutoRoutersPanel } from "../components/AutoRouters/AutoRoutersPanel"; + +/** + * Owns the permission decision for the Auto-Routers tab so the panel stays a renderer. + * Creating or editing an auto router is a POST /model/new or PATCH /model/{id}/update, both + * proxy-admin gated, so viewer roles read the list without write affordances. + */ +export default function AutoRoutersTabPanel() { + const { accessToken, userRole } = useAuthorized(); + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx index c4cfa98b2a1..9ffcbfc9975 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index fa3447e0cbf..ed7b17067d5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -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 = ({ accessToken, user return (
- - - Loadbalancing - Routing Groups - Fallbacks - Prompt Caching - General - - - - - - - - - - - - - - - - - - - - Setting - Value - Status - Action - - - - {generalSettings - .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) - .map((value, index) => ( - - - {value.field_name} -

- {value.field_description} -

-
- - - - - {value.stored_in_db == true ? ( - - ) : value.stored_in_db == false ? ( - - ) : ( - - )} - - - - handleResetField(value.field_name)}> - Reset - - -
- ))} -
-
-
-
-
-
+ + + Loadbalancing + Routing Groups + Fallbacks + Prompt Caching + General + + + + + + + + + + + + + + + + + + + Setting + Value + Status + Action + + + + {generalSettings + .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) + .map((value, index) => ( + + + {value.field_name} +

+ {value.field_description} +

+
+ + + + + {value.stored_in_db == true ? ( + + ) : value.stored_in_db == false ? ( + + ) : ( + + )} + + + + handleResetField(value.field_name)}> + Reset + + +
+ ))} +
+
+
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx index b4c22a834a4..040ea2cb440 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.test.tsx @@ -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(); + + 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(); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index 4713f8c6869..3328c1703a8 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -1,7 +1,6 @@ import { renderWithProviders, screen } 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"; @@ -21,10 +20,7 @@ vi.mock("../molecules/notifications_manager", () => ({ default: { fromBackend: vi.fn() }, })); -const Harness = () => { - const [form] = Form.useForm(); - return ; -}; +const Harness = () => ; describe("AddAutoRouterTab", () => { it("flags every mandatory field when Add Auto Router is clicked with nothing filled", async () => { diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index e7826e09dce..6e946bf9106 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -1,13 +1,10 @@ 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 { 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 +19,22 @@ 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; } -type RouterType = "recommended" | "semantic"; - const { Title } = Typography; -const AddAutoRouterTab: React.FC = ({ form, handleOk, accessToken, userRole }) => { +const AddAutoRouterTab: React.FC = ({ handleOk, accessToken, userRole }) => { + const [form] = Form.useForm(); const [modelAccessGroups, setModelAccessGroups] = useState([]); const [modelInfo, setModelInfo] = useState([]); - const [routerType, setRouterType] = useState("recommended"); - const [complexityRouterConfig, setComplexityRouterConfig] = useState({ tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, classifier_type: "heuristic", @@ -56,9 +48,6 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc const [escalationKeywords, setEscalationKeywords] = useState(DEFAULT_ESCALATION_KEYWORDS); const [showValidationErrors, setShowValidationErrors] = useState(false); - // Semantic router config (existing) - const [routerConfig, setRouterConfig] = useState(null); - const [isTestModalVisible, setIsTestModalVisible] = useState(false); const [isTestingConnection, setIsTestingConnection] = useState(false); const [connectionTestId, setConnectionTestId] = useState(0); @@ -169,40 +158,6 @@ const AddAutoRouterTab: React.FC = ({ 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 +167,7 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc return; } - if (routerType === "recommended") { - submitRecommendedRouter(name); - } else { - submitSemanticRouter(name); - } + submitRecommendedRouter(name); }; const handleTestConnection = () => { @@ -239,53 +190,6 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc return ( <> - Add Auto Router - - 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. - - - -
- Router Type - { - setRouterType(e.target.value); - setShowValidationErrors(false); - }} - className="w-full" - > - - -
- - Auto-Router v2 - -
-
- Routes by request complexity across four tiers, with optional keyword-to-tier overrides and semantic - keyword matching. No training data needed. -
-
- -
- - Semantic Router [to be deprecated] -
-
- Routes based on semantic similarity to example utterances. Requires an embedding model and example - utterances. -
-
-
-
-
-
-
= ({ form, handleOk, acc - {routerType === "recommended" ? ( -
- -
- ) : ( - <> -
- { - setRouterConfig(config); - form.setFieldValue("auto_router_config", config); - }} - /> -
- - - - - - - - - - )} +
+ +
@@ -408,7 +265,7 @@ const AddAutoRouterTab: React.FC = ({ form, handleOk, acc Need Help?
- {routerType === "recommended" && ( + { - )} + }
- {(!isAutoRouter || isComplexityRouter) && ( + {(!isAnyAutoRouter || isComplexityRouterModel) && ( )} - + {!isAnyAutoRouter && ( + <> + - + + + )}
@@ -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.

- {canModify && ( + {canCreate && (