From 516953b073abbbeadfa0f6be635cf1e87e65a9a4 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 30 Jul 2026 00:18:45 -0700 Subject: [PATCH] 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 && (