From baeda235bb9b0474b80fc572a73e4da766d247e2 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 11:19:45 -0700 Subject: [PATCH 1/6] feat(ui): expose Azure Entra ID credential fields in provider form Adds tenant_id, client_id, and client_secret to the Azure provider entry in provider_create_fields.json so the credential add/edit modals and the add-model form surface Service Principal auth as an alternative to api_key. The Azure handler already reads these fields from litellm_params at request time via get_azure_ad_token(); this change makes them inputtable from the UI without code changes to the React components (the form is driven by GET /public/providers/fields). --- .../provider_create_fields.json | 30 +++++++++++++++++++ .../public_endpoints/test_public_endpoints.py | 28 +++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index dda8e49d4c..860593a6ea 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -406,6 +406,36 @@ "field_type": "password", "options": null, "default_value": null + }, + { + "key": "tenant_id", + "label": "Tenant ID", + "placeholder": "Enter your Azure AD tenant ID", + "tooltip": "Entra ID (Service Principal) auth. Provide tenant id, client id, and client secret together as an alternative to api key.", + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "client_id", + "label": "Client ID", + "placeholder": "Enter your Service Principal client ID", + "tooltip": "Entra ID (Service Principal) auth. Provide tenant id, client id, and client secret together as an alternative to api key.", + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "client_secret", + "label": "Client Secret", + "placeholder": "Enter your Service Principal client secret", + "tooltip": "Entra ID (Service Principal) auth. Provide tenant id, client id, and client secret together as an alternative to api key.", + "required": false, + "field_type": "password", + "options": null, + "default_value": null } ], "default_model_placeholder": "azure/my-deployment" diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 53c98c8c40..d62f88bf16 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -110,6 +110,34 @@ def test_watsonx_provider_fields(): assert "zen_api_key" in field_keys +def test_azure_provider_fields_include_entra_id(): + """Azure provider must expose Entra ID (Service Principal) credential fields so + the UI can input tenant_id / client_id / client_secret as an alternative to api_key.""" + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.get("/public/providers/fields") + providers = response.json() + + azure = next((p for p in providers if p["provider"] == "Azure"), None) + assert azure is not None + + fields_by_key = {f["key"]: f for f in azure["credential_fields"]} + # API-key auth still supported + assert "api_key" in fields_by_key + # Entra ID fields + assert "tenant_id" in fields_by_key + assert "client_id" in fields_by_key + assert "client_secret" in fields_by_key + # client_secret must be masked in the UI + assert fields_by_key["client_secret"]["field_type"] == "password" + # Entra ID is an alternative to api_key, so none of these are individually required + assert fields_by_key["tenant_id"]["required"] is False + assert fields_by_key["client_id"]["required"] is False + assert fields_by_key["client_secret"]["required"] is False + + def test_public_model_hub_with_healthy_model(): """Test that health information is populated for a healthy model""" app = FastAPI() From 11a43d6e509b074b9165042ceb3a4f8c0c2ae29c Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sat, 4 Apr 2026 13:35:14 -0700 Subject: [PATCH 2/6] feat(ui): add per-model rate limits to team edit/info views Exposes the backend's existing model_tpm_limit/model_rpm_limit fields (which lived in team.metadata) through a new "Model-Specific Rate Limits" form section on the team Settings tab. Limits round-trip through the team-update API and render on the Overview card and Settings view. Model picker is scoped to the team's currently-selected models (unfurls wildcards, falls back to userModels for all-proxy-models / all-team-models). --- .../src/components/team/TeamInfo.tsx | 141 +++++++++++++++++- 1 file changed, 138 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index a4c7ae2bbb..346686d019 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -17,10 +17,10 @@ import { import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; import { isProxyAdminRole } from "@/utils/roles"; -import { EditOutlined, InfoCircleOutlined, SaveOutlined } from "@ant-design/icons"; +import { EditOutlined, InfoCircleOutlined, MinusCircleOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Card, Grid, Text, TextInput, Title } from "@tremor/react"; -import { Button, Form, Input, Select, Switch, Tabs, Tooltip } from "antd"; +import { Button, Form, Input, InputNumber, Select, Space, Switch, Tabs, Tooltip } from "antd"; import MessageManager from "@/components/molecules/message_manager"; import { CheckIcon, CopyIcon } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; @@ -195,6 +195,17 @@ const TeamInfoView: React.FC = ({ return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false; }, [teamData, userOrganizations, userId]); + // Models currently selected in the team edit form, used to scope the per-model + // rate limit dropdown to models this team actually has access to. + const selectedModelsInForm = Form.useWatch("models", form) as string[] | undefined; + const availableRateLimitModels = useMemo(() => { + const selected = selectedModelsInForm ?? teamData?.team_info?.models ?? []; + if (selected.includes("all-proxy-models") || selected.includes("all-team-models")) { + return userModels; + } + return unfurlWildcardModelsInList(selected, userModels); + }, [selectedModelsInForm, teamData, userModels]); + const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam; const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo( @@ -467,12 +478,23 @@ const TeamInfoView: React.FC = ({ return v; }; + const modelTpmLimit: Record = {}; + const modelRpmLimit: Record = {}; + for (const entry of (values.modelLimits ?? []) as { model?: string; tpm?: number; rpm?: number }[]) { + if (entry?.model) { + if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm; + if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm; + } + } + const updateData: any = { team_id: teamId, team_alias: values.team_alias, models: values.models, tpm_limit: sanitizeNumeric(values.tpm_limit), rpm_limit: sanitizeNumeric(values.rpm_limit), + model_tpm_limit: modelTpmLimit, + model_rpm_limit: modelRpmLimit, max_budget: values.max_budget, soft_budget: sanitizeNumeric(values.soft_budget), budget_duration: values.budget_duration, @@ -649,6 +671,22 @@ const TeamInfoView: React.FC = ({ TPM: {info.tpm_limit || "Unlimited"} RPM: {info.rpm_limit || "Unlimited"} {info.max_parallel_requests && Max Parallel Requests: {info.max_parallel_requests}} + {(() => { + const modelTpm = (info.metadata?.model_tpm_limit ?? {}) as Record; + const modelRpm = (info.metadata?.model_rpm_limit ?? {}) as Record; + const models = Array.from(new Set([...Object.keys(modelTpm), ...Object.keys(modelRpm)])); + if (models.length === 0) return null; + return ( +
+ Per-model limits: + {models.map((m) => ( + + {m}: TPM {modelTpm[m] ?? "—"}, RPM {modelRpm[m] ?? "—"} + + ))} +
+ ); + })()} @@ -794,6 +832,16 @@ const TeamInfoView: React.FC = ({ models: info.models, tpm_limit: info.tpm_limit, rpm_limit: info.rpm_limit, + modelLimits: Array.from( + new Set([ + ...Object.keys(info.metadata?.model_tpm_limit ?? {}), + ...Object.keys(info.metadata?.model_rpm_limit ?? {}), + ]), + ).map((model) => ({ + model, + tpm: info.metadata?.model_tpm_limit?.[model], + rpm: info.metadata?.model_rpm_limit?.[model], + })), max_budget: info.max_budget, soft_budget: info.soft_budget, budget_duration: info.budget_duration, @@ -810,7 +858,7 @@ const TeamInfoView: React.FC = ({ : "", metadata: info.metadata ? JSON.stringify( - (({ logging, secret_manager_settings, soft_budget_alerting_emails, ...rest }) => rest)(info.metadata), + (({ logging, secret_manager_settings, soft_budget_alerting_emails, model_tpm_limit, model_rpm_limit, ...rest }) => rest)(info.metadata), null, 2, ) @@ -935,6 +983,77 @@ const TeamInfoView: React.FC = ({ + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + { + if (!value) return Promise.resolve(); + const all = form.getFieldValue("modelLimits") ?? []; + const dupes = all.filter( + (entry: { model?: string }) => entry?.model === value, + ); + if (dupes.length > 1) { + return Promise.reject(new Error("Duplicate model")); + } + return Promise.resolve(); + }, + }, + ]} + style={{ minWidth: 240 }} + > +