diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 05c1bfab21d..8c6e47ea793 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -2378,6 +2378,19 @@
"title": "Agent Name",
"type": "string"
},
+ "enabled": {
+ "title": "Enabled",
+ "type": "boolean"
+ },
+ "execution_mode": {
+ "enum": [
+ "autonomous",
+ "delegated",
+ "both"
+ ],
+ "title": "Execution Mode",
+ "type": "string"
+ },
"extra_headers": {
"anyOf": [
{
@@ -2392,6 +2405,16 @@
],
"title": "Extra Headers"
},
+ "identity": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/EntraIdentityConfig"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"kill_switch": {
"anyOf": [
{
@@ -2470,8 +2493,7 @@
}
},
"required": [
- "agent_name",
- "agent_card_params"
+ "agent_name"
],
"title": "AgentConfig",
"type": "object"
@@ -3537,6 +3559,61 @@
"title": "DailySpendMetadata",
"type": "object"
},
+ "EntraIdentityConfig": {
+ "additionalProperties": false,
+ "properties": {
+ "client_id": {
+ "title": "Client Id",
+ "type": "string"
+ },
+ "provider": {
+ "const": "microsoft_entra",
+ "title": "Provider",
+ "type": "string"
+ },
+ "required_roles": {
+ "default": [],
+ "items": {
+ "type": "string"
+ },
+ "title": "Required Roles",
+ "type": "array"
+ },
+ "required_scopes": {
+ "default": [
+ "user_impersonation"
+ ],
+ "description": "Required delegated scopes. An empty list accepts any nonempty scope granted for this gateway.",
+ "items": {
+ "type": "string"
+ },
+ "title": "Required Scopes",
+ "type": "array"
+ },
+ "service_principal_id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Service Principal Id"
+ },
+ "tenant_id": {
+ "title": "Tenant Id",
+ "type": "string"
+ }
+ },
+ "required": [
+ "provider",
+ "tenant_id",
+ "client_id"
+ ],
+ "title": "EntraIdentityConfig",
+ "type": "object"
+ },
"HTTPAuthSecurityScheme": {
"description": "Defines a security scheme using HTTP authentication.",
"properties": {
@@ -3686,6 +3763,54 @@
"title": "MakeAgentsPublicRequest",
"type": "object"
},
+ "ManagedAgentIdentityStatus": {
+ "properties": {
+ "enabled": {
+ "default": true,
+ "title": "Enabled",
+ "type": "boolean"
+ },
+ "execution_mode": {
+ "default": "autonomous",
+ "enum": [
+ "autonomous",
+ "delegated",
+ "both"
+ ],
+ "title": "Execution Mode",
+ "type": "string"
+ },
+ "identity": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/AgentIdentityBinding"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "identity_managed": {
+ "default": false,
+ "title": "Identity Managed",
+ "type": "boolean"
+ },
+ "last_authenticated_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Authenticated At"
+ }
+ },
+ "title": "ManagedAgentIdentityStatus",
+ "type": "object"
+ },
"MetricWithMetadata": {
"properties": {
"api_key_breakdown": {
@@ -3886,6 +4011,19 @@
"title": "Agent Name",
"type": "string"
},
+ "enabled": {
+ "title": "Enabled",
+ "type": "boolean"
+ },
+ "execution_mode": {
+ "enum": [
+ "autonomous",
+ "delegated",
+ "both"
+ ],
+ "title": "Execution Mode",
+ "type": "string"
+ },
"extra_headers": {
"anyOf": [
{
@@ -3900,6 +4038,16 @@
],
"title": "Extra Headers"
},
+ "identity": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/EntraIdentityConfig"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
"kill_switch": {
"anyOf": [
{
@@ -4421,6 +4569,36 @@
]
}
},
+ "/v1/agents/identity/providers": {
+ "get": {
+ "operationId": "get_agent_identity_providers_v1_agents_identity_providers_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Response Get Agent Identity Providers V1 Agents Identity Providers Get",
+ "type": "array"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Get Agent Identity Providers",
+ "tags": [
+ "agents"
+ ]
+ }
+ },
"/v1/agents/make_public": {
"post": {
"description": "Make multiple agents publicly discoverable\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/v1/agents/make_public\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"agent_ids\": [\"123e4567-e89b-12d3-a456-426614174000\", \"123e4567-e89b-12d3-a456-426614174001\"]\n }'\n```\n\nExample Response:\n```json\n{\n \"agent_id\": \"123e4567-e89b-12d3-a456-426614174000\",\n \"agent_name\": \"my-custom-agent\",\n \"litellm_params\": {\n \"make_public\": true\n },\n \"agent_card_params\": {...},\n \"created_at\": \"2025-11-15T10:30:00Z\",\n \"updated_at\": \"2025-11-15T10:35:00Z\",\n \"created_by\": \"user123\",\n \"updated_by\": \"user123\"\n}\n```",
@@ -4672,6 +4850,53 @@
]
}
},
+ "/v1/agents/{agent_id}/identity": {
+ "get": {
+ "operationId": "get_agent_identity_status_v1_agents__agent_id__identity_get",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "agent_id",
+ "required": true,
+ "schema": {
+ "title": "Agent Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ManagedAgentIdentityStatus"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Get Agent Identity Status",
+ "tags": [
+ "agents"
+ ]
+ }
+ },
"/v1/agents/{agent_id}/kill_switch": {
"post": {
"description": "Fire the agent's configured kill switch webhook. Proxy admin only.\n\nLiteLLM only makes the configured HTTP call and reports what came back; it\ndoes not change the agent's state in LiteLLM. Returns 200 when the webhook\nanswered 2xx, 502 with the same result body otherwise. Every attempt is\nwritten to the audit log as a `kill_switch_fired` row against the agent.\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/v1/agents/123e4567-e89b-12d3-a456-426614174000/kill_switch\" \\\n -H \"Authorization: Bearer \"\n```",
diff --git a/litellm/proxy/agent_endpoints/agent_registry.py b/litellm/proxy/agent_endpoints/agent_registry.py
index 3e775d7648e..7929f67720d 100644
--- a/litellm/proxy/agent_endpoints/agent_registry.py
+++ b/litellm/proxy/agent_endpoints/agent_registry.py
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypedDict
+from fastapi import HTTPException
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly
@@ -14,16 +15,24 @@ from litellm.constants import REDACTED_BY_LITELM_STRING
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.proxy.agent_endpoints.kill_switch import restore_kill_switch
+from litellm.proxy.agent_endpoints.managed_identity import managed_write_fields, raise_identity_failure
from litellm.proxy.management_helpers.object_permission_utils import (
- handle_update_object_permission_common,
+ prepare_object_permission_upsert,
)
from litellm.proxy.utils import PrismaClient
+from litellm.repositories.base_repository import is_unique_violation
from litellm.repositories.prisma_protocols import TableActions
-from litellm.repositories.table_repositories import AgentsRepository, ObjectPermissionRepository
+from litellm.repositories.table_repositories import (
+ AgentsRepository,
+ ObjectPermissionRepository,
+ RetiredAgentIdentityRepository,
+)
from litellm.types.agents import AgentConfig, AgentKillSwitchConfig, AgentResponse, PatchAgentRequest
+from litellm.types.proxy.agent_identity import AgentIdentityFailure
if TYPE_CHECKING:
from prisma import models as prisma_models
+ from prisma.types import LiteLLM_RetiredAgentIdentityWhereUniqueInput
class AgentObjectPermissionRecord(Protocol):
@@ -135,6 +144,56 @@ def object_permission_table(
return table
+class AgentPermissionWrite(TypedDict, total=False):
+ create: ReadOnly[Mapping[str, object]]
+ update: ReadOnly[Mapping[str, object]]
+
+
+async def _permission_write(
+ incoming: Mapping[str, object],
+ existing_id: str | None,
+ client: PrismaClient,
+) -> AgentPermissionWrite | None:
+ raw: Final = incoming.get("object_permission")
+ if raw is None:
+ return None
+ permission: Final = _AGENT_PARAMS_ADAPTER.validate_python(raw)
+ prepared: Final = await prepare_object_permission_upsert(permission, existing_id, client)
+ if existing_id is None:
+ created: Final[AgentPermissionWrite] = {"create": prepared.record}
+ return created
+ updated: Final[AgentPermissionWrite] = {"update": prepared.record}
+ return updated
+
+
+async def _managed_fields(
+ incoming: Mapping[str, object],
+ existing: AgentResponse | None,
+ updated_by: str,
+ client: PrismaClient,
+) -> Mapping[str, object]:
+ result: Final = managed_write_fields(incoming, existing, updated_by)
+ if isinstance(result, AgentIdentityFailure):
+ raise_identity_failure(result, 400)
+ history: Final = result.get("retired_identities")
+ if history is None:
+ return result
+ entry: Final = history["create"]
+ where: Final[LiteLLM_RetiredAgentIdentityWhereUniqueInput] = {
+ "provider_tenant_id_client_id": {
+ "provider": entry["provider"],
+ "tenant_id": entry["tenant_id"],
+ "client_id": entry["client_id"],
+ }
+ }
+ prior: Final = await RetiredAgentIdentityRepository(client, use_writer=True).table.find_unique(where=where)
+ if prior is None:
+ return result
+ if existing is None or prior.agent_id != existing.agent_id:
+ raise HTTPException(409, "Entra application was already registered to another agent")
+ return MappingProxyType({key: value for key, value in result.items() if key != "retired_identities"})
+
+
def _dump_agent_params(raw: Mapping[str, object]) -> dict[str, object]:
model_dump: Final[Callable[[], dict[str, object]] | None] = getattr(raw, "model_dump", None)
if model_dump is not None:
@@ -552,11 +611,7 @@ class AgentRegistry:
agent_card_params_dict: Final[dict[str, object]] = _dump_agent_params(agent_card_params_obj)
agent_card_params: Final[str] = safe_dumps(agent_card_params_dict)
- # Handle object_permission (MCP tool access for agent)
- object_permission_id: str | None = None
- if agent.get("object_permission") is not None:
- agent_copy: Final = dict(agent)
- object_permission_id = await handle_update_object_permission_common(agent_copy, None, prisma_client)
+ permission_write: Final = await _permission_write(agent, None, prisma_client)
# Serialize static_headers
static_headers_obj: Final = agent.get("static_headers")
@@ -583,8 +638,8 @@ class AgentRegistry:
create_data["extra_headers"] = extra_headers_val
if access_group_ids_val is not None:
create_data["access_group_ids"] = tuple(dict.fromkeys(access_group_ids_val))
- if object_permission_id is not None:
- create_data["object_permission_id"] = object_permission_id
+ if permission_write is not None:
+ create_data["object_permission"] = permission_write
for rate_field in (
"tpm_limit",
@@ -598,31 +653,46 @@ class AgentRegistry:
# Create agent in DB
created_agent: Final = await agents_table(prisma_client).create(
- data=create_data,
- include={"object_permission": True},
+ data={**create_data, **await _managed_fields(agent, None, created_by, prisma_client)},
+ include={"object_permission": True, "identity": True},
)
- created_agent_dict: Final = created_agent.model_dump()
- if created_agent.object_permission is not None:
- try:
- created_agent_dict["object_permission"] = created_agent.object_permission.model_dump()
- except Exception:
- created_agent_dict["object_permission"] = created_agent.object_permission.dict()
- return AgentResponse(**created_agent_dict)
+ return AgentResponse.model_validate(created_agent.model_dump())
+ except HTTPException:
+ raise
except Exception as e:
- raise Exception(f"Error adding agent to DB: {e}")
+ if is_unique_violation(e):
+ raise HTTPException(409, "Agent name or Entra application is already registered") from e
+ raise
async def delete_agent_from_db(self, agent_id: str, prisma_client: PrismaClient) -> Mapping[str, object]:
"""
Delete an agent from the database
"""
- try:
- deleted_agent: Final = await agents_table(prisma_client).delete(where={"agent_id": agent_id})
+ from prisma.types import (
+ LiteLLM_AgentsTableWhereUniqueInput,
+ LiteLLM_RetiredAgentCreateInput,
+ LiteLLM_RetiredAgentUpsertInput,
+ LiteLLM_RetiredAgentWhereUniqueInput,
+ LiteLLM_VerificationTokenWhereInput,
+ )
+
+ where: Final[LiteLLM_AgentsTableWhereUniqueInput] = {"agent_id": agent_id}
+ async with prisma_client.tx() as tx:
+ existing: Final = await tx.litellm_agentstable.find_unique(where=where)
+ if existing is None:
+ raise ValueError(f"Agent not found, passed agent_id={agent_id}")
+ if existing.identity_managed:
+ history_where: Final[LiteLLM_RetiredAgentWhereUniqueInput] = {"original_agent_id": agent_id}
+ history_create: Final = LiteLLM_RetiredAgentCreateInput(original_agent_id=agent_id)
+ history_data: Final[LiteLLM_RetiredAgentUpsertInput] = {"create": history_create, "update": {}}
+ await tx.litellm_retiredagent.upsert(where=history_where, data=history_data)
+ keys_where: Final[LiteLLM_VerificationTokenWhereInput] = {"agent_id": agent_id}
+ await tx.litellm_verificationtoken.delete_many(where=keys_where)
+ deleted_agent: Final = await tx.litellm_agentstable.delete(where=where)
if deleted_agent is None:
raise ValueError(f"Agent not found, passed agent_id={agent_id}")
- return dict(deleted_agent)
- except Exception as e:
- raise Exception(f"Error deleting agent from DB: {e}")
+ return deleted_agent.model_dump()
async def patch_agent_in_db(
self,
@@ -646,7 +716,9 @@ class AgentRegistry:
The patched agent
"""
try:
- existing_record: Final = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
+ existing_record: Final = await agents_table(prisma_client).find_unique(
+ where={"agent_id": agent_id}, include={"identity": True}
+ )
if existing_record is None:
raise Exception(f"Agent with ID {agent_id} not found")
existing_agent: Final[Mapping[str, object]] = dict(existing_record)
@@ -683,37 +755,33 @@ class AgentRegistry:
if "extra_headers" in agent:
extra_headers_value: Final = agent.get("extra_headers")
update_data["extra_headers"] = extra_headers_value if extra_headers_value is not None else []
- if agent.get("object_permission") is not None:
- agent_copy: Final = dict(augment_agent)
- existing_object_permission_id: Final = existing_record.object_permission_id
- object_permission_id: Final = await handle_update_object_permission_common(
- agent_copy,
- existing_object_permission_id,
- prisma_client,
- )
- if object_permission_id is not None:
- update_data["object_permission_id"] = object_permission_id
+ permission_write: Final = await _permission_write(
+ agent, existing_record.object_permission_id, prisma_client
+ )
+ if permission_write is not None:
+ update_data["object_permission"] = permission_write
# Patch agent in DB
patched_agent: Final = await agents_table(prisma_client).update(
where={"agent_id": agent_id},
data={
**update_data,
+ **await _managed_fields(
+ agent, AgentResponse.model_validate(existing_record.model_dump()), updated_by, prisma_client
+ ),
"updated_by": updated_by,
"updated_at": datetime.now(timezone.utc),
},
- include={"object_permission": True},
+ include={"object_permission": True, "identity": True},
)
if patched_agent is None:
raise ValueError(f"Agent not found, passed agent_id={agent_id}")
- patched_agent_dict: Final = patched_agent.model_dump()
- if patched_agent.object_permission is not None:
- try:
- patched_agent_dict["object_permission"] = patched_agent.object_permission.model_dump()
- except Exception:
- patched_agent_dict["object_permission"] = patched_agent.object_permission.dict()
- return AgentResponse(**patched_agent_dict)
+ return AgentResponse.model_validate(patched_agent.model_dump())
+ except HTTPException:
+ raise
except Exception as e:
- raise Exception(f"Error patching agent in DB: {e}")
+ if is_unique_violation(e):
+ raise HTTPException(409, "Agent name or Entra application is already registered") from e
+ raise
async def update_agent_in_db(
self,
@@ -725,6 +793,13 @@ class AgentRegistry:
"""
Update an agent in the database
"""
+ if "agent_card_params" not in agent:
+ return await self.patch_agent_in_db(
+ agent_id=agent_id,
+ agent=PatchAgentRequest(**agent),
+ prisma_client=prisma_client,
+ updated_by=updated_by,
+ )
try:
agent_name: Final = agent.get("agent_name")
@@ -733,7 +808,7 @@ class AgentRegistry:
# caller echoed back redacted (or omitted) rather than persisting
# the marker -- or nothing -- over the real stored credential.
existing_row: Final = await agents_table(prisma_client).find_unique(
- where={"agent_id": agent_id} # mutable-ok: prisma's query builder rejects a Mapping/MappingProxyType
+ where={"agent_id": agent_id}, include={"identity": True}
)
existing_litellm_params: Final = parse_agent_litellm_params(
existing_row.litellm_params if existing_row is not None else None
@@ -784,37 +859,36 @@ class AgentRegistry:
if _val is not None:
update_data[rate_field] = _val
- if agent.get("object_permission") is not None:
- existing_object_permission_id: Final = (
- existing_row.object_permission_id if existing_row is not None else None
- )
- agent_copy: Final = dict(agent)
- object_permission_id: Final = await handle_update_object_permission_common(
- agent_copy,
- existing_object_permission_id,
- prisma_client,
- )
- if object_permission_id is not None:
- update_data["object_permission_id"] = object_permission_id
+ permission_write: Final = await _permission_write(
+ agent, existing_row.object_permission_id if existing_row is not None else None, prisma_client
+ )
+ if permission_write is not None:
+ update_data["object_permission"] = permission_write
# Update agent in DB
updated_agent: Final = await agents_table(prisma_client).update(
where={"agent_id": agent_id},
- data=update_data,
- include={"object_permission": True},
+ data={
+ **update_data,
+ **await _managed_fields(
+ agent,
+ AgentResponse.model_validate(existing_row.model_dump()) if existing_row else None,
+ updated_by,
+ prisma_client,
+ ),
+ },
+ include={"object_permission": True, "identity": True},
)
if updated_agent is None:
raise ValueError(f"Agent not found, passed agent_id={agent_id}")
- updated_agent_dict: Final = updated_agent.model_dump()
- if updated_agent.object_permission is not None:
- try:
- updated_agent_dict["object_permission"] = updated_agent.object_permission.model_dump()
- except Exception:
- updated_agent_dict["object_permission"] = updated_agent.object_permission.dict()
- return AgentResponse(**updated_agent_dict)
+ return AgentResponse.model_validate(updated_agent.model_dump())
+ except HTTPException:
+ raise
except Exception as e:
- raise Exception(f"Error updating agent in DB: {e}")
+ if is_unique_violation(e):
+ raise HTTPException(409, "Agent name or Entra application is already registered") from e
+ raise
@staticmethod
async def get_all_agents_from_db(
@@ -826,12 +900,12 @@ class AgentRegistry:
try:
agents_from_db: Final = await agents_table(prisma_client).find_many(
order={"created_at": "desc"},
- include={"object_permission": True},
+ include={"object_permission": True, "identity": True},
)
agents: Final[list[dict[str, object]]] = []
for agent in agents_from_db:
- agent_dict = dict(agent)
+ agent_dict = agent.model_dump()
# object_permission is eagerly loaded via include above
if agent.object_permission is not None:
try:
diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py
index 28c82a715e0..e1b2ac63d51 100644
--- a/litellm/proxy/agent_endpoints/endpoints.py
+++ b/litellm/proxy/agent_endpoints/endpoints.py
@@ -16,6 +16,7 @@ from types import MappingProxyType
from typing import Annotated, Final, TypedDict
from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from pydantic import ValidationError
from typing_extensions import ReadOnly, Required, assert_never
import litellm
@@ -47,6 +48,8 @@ from litellm.proxy.agent_endpoints.agent_search import (
search_agents,
)
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import accessible_agents
+from litellm.proxy.agent_endpoints.identity import reject_legacy_identity
+from litellm.proxy.agent_endpoints.identity_store import AgentIdentityStore
from litellm.proxy.agent_endpoints.kill_switch import (
KillSwitchAuditLogWriter,
KillSwitchHttpClient,
@@ -56,6 +59,7 @@ from litellm.proxy.agent_endpoints.kill_switch import (
fire_kill_switch,
redact_kill_switch,
)
+from litellm.proxy.agent_endpoints.managed_identity import raise_identity_failure
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
@@ -72,6 +76,12 @@ from litellm.types.agents import (
PatchAgentRequest,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
+from litellm.types.proxy.agent_identity import (
+ AgentIdentityBinding,
+ AgentIdentityFailure,
+ EntraIdentityConfig,
+ ManagedAgentIdentityStatus,
+)
from litellm.types.proxy.management_endpoints.common_daily_activity import (
DailySpendMetadata,
SpendAnalyticsPaginatedResponse,
@@ -178,14 +188,21 @@ def _redact_sensitive_agent_fields(
virtual-key, header and kill-switch fields stripped entirely. The original
objects are not modified.
"""
+ from litellm.proxy.proxy_server import general_settings, jwt_handler
+
redacted: Final[list[AgentResponse]] = []
for agent in agents:
copy = agent.model_copy(deep=True)
+ copy.jwt_auth_configured = bool(
+ general_settings.get("enable_jwt_auth")
+ and (agent.identity is not None or jwt_handler.litellm_jwtauth.agent_id_jwt_field)
+ )
if not is_admin:
copy.static_headers = None
copy.extra_headers = None
copy.keys = None
copy.kill_switch = None
+ copy.identity = None
if copy.litellm_params:
copy.litellm_params = _redact_agent_litellm_params_dict(copy.litellm_params)
copy.kill_switch = redact_kill_switch(copy.kill_switch)
@@ -429,6 +446,71 @@ from litellm.proxy.agent_endpoints.agent_registry import (
)
+def _trusted_agent_issuers() -> tuple[str, ...]:
+ from litellm.proxy.proxy_server import general_settings, jwt_handler
+
+ if not general_settings.get("enable_jwt_auth"):
+ return ()
+ configured: Final = jwt_handler.litellm_jwtauth.issuers or ()
+ issuer: Final = os.getenv("JWT_ISSUER")
+ global_issuers: Final = (
+ (issuer,)
+ if issuer and os.getenv("JWT_AUDIENCE") and not any(item.issuer == issuer for item in configured)
+ else ()
+ )
+ return (
+ tuple(item.issuer for item in configured if item.audience and not item.disable_audience_validation)
+ + global_issuers
+ )
+
+
+def _validate_managed_identity_request(
+ request: AgentConfig | PatchAgentRequest, existing: AgentResponse | None = None
+) -> None:
+ raw: Final = request.get("identity") if "identity" in request else existing.identity if existing else None
+ if raw is None:
+ return
+ try:
+ identity: Final = raw if isinstance(raw, AgentIdentityBinding) else EntraIdentityConfig.model_validate(raw)
+ except ValidationError as exc:
+ raise HTTPException(400, "Invalid Entra identity configuration") from exc
+ if identity.issuer not in _trusted_agent_issuers():
+ raise HTTPException(400, "Configure trusted JWT issuer and audience validation for this Entra tenant first")
+ if request.get("execution_mode", existing.execution_mode if existing else "autonomous") != "autonomous":
+ if os.getenv("MICROSOFT_TENANT") != identity.tenant_id or not os.getenv("MICROSOFT_CLIENT_ID"):
+ raise HTTPException(400, "Delegated agents require Microsoft SSO for the same trusted tenant")
+
+
+@router.get("/v1/agents/identity/providers", response_model=tuple[str, ...], tags=("[beta] A2A Agents",))
+async def get_agent_identity_providers(
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+) -> tuple[str, ...]:
+ _check_agent_management_permission(user_api_key_dict)
+ return _trusted_agent_issuers()
+
+
+@router.get("/v1/agents/{agent_id}/identity", response_model=ManagedAgentIdentityStatus, tags=("[beta] A2A Agents",))
+async def get_agent_identity_status(
+ agent_id: str,
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+) -> ManagedAgentIdentityStatus:
+ from litellm.proxy.proxy_server import prisma_client
+
+ _check_agent_management_permission(user_api_key_dict)
+ agent: Final = await AgentIdentityStore.from_client(prisma_client).agent(agent_id)
+ if isinstance(agent, AgentIdentityFailure):
+ raise_identity_failure(agent)
+ if agent is None:
+ raise HTTPException(404, "Agent not found")
+ return ManagedAgentIdentityStatus(
+ identity=agent.identity,
+ identity_managed=agent.identity_managed,
+ enabled=agent.enabled,
+ execution_mode=agent.execution_mode,
+ last_authenticated_at=agent.identity.last_authenticated_at if agent.identity else None,
+ )
+
+
@router.post(
"/v1/agents",
tags=["[beta] A2A Agents"],
@@ -490,6 +572,9 @@ async def create_agent(
# Get the user ID from the API key auth
created_by: Final = user_api_key_dict.user_id or "unknown"
+ _validate_managed_identity_request(request)
+ reject_legacy_identity(request.get("litellm_params"))
+
# check for naming conflicts
existing_agent: Final = AGENT_REGISTRY.get_agent_by_name(agent_name=request.get("agent_name"))
if existing_agent is not None:
@@ -591,7 +676,7 @@ async def get_agent_by_id(
if agent is None:
agent_row: Final = await agents_table(prisma_client).find_unique(
where={"agent_id": agent_id},
- include={"object_permission": True},
+ include={"object_permission": True, "identity": True},
)
if agent_row is not None:
agent_dict: Final = agent_row.model_dump()
@@ -680,13 +765,18 @@ async def update_agent(
try:
# Check if agent exists
- existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
+ existing_agent = await agents_table(prisma_client).find_unique(
+ where={"agent_id": agent_id}, include={"identity": True}
+ )
if existing_agent is not None:
- existing_agent = dict(existing_agent)
+ existing_agent = existing_agent.model_dump()
if existing_agent is None:
raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found")
+ _validate_managed_identity_request(request, AgentResponse.model_validate(existing_agent))
+ reject_legacy_identity(request.get("litellm_params"))
+
# Get the user ID from the API key auth
updated_by: Final = user_api_key_dict.user_id or "unknown"
@@ -782,13 +872,18 @@ async def patch_agent(
try:
# Check if agent exists
- existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
+ existing_agent = await agents_table(prisma_client).find_unique(
+ where={"agent_id": agent_id}, include={"identity": True}
+ )
if existing_agent is not None:
- existing_agent = dict(existing_agent)
+ existing_agent = existing_agent.model_dump()
if existing_agent is None:
raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found")
+ _validate_managed_identity_request(request, AgentResponse.model_validate(existing_agent))
+ reject_legacy_identity(request.get("litellm_params"))
+
# Get the user ID from the API key auth
updated_by: Final = user_api_key_dict.user_id or "unknown"
@@ -869,7 +964,9 @@ async def delete_agent(
try:
# Check if agent exists
- existing_agent = await agents_table(prisma_client).find_unique(where={"agent_id": agent_id})
+ existing_agent = await agents_table(prisma_client).find_unique(
+ where={"agent_id": agent_id}, include={"identity": True}
+ )
if existing_agent is not None:
existing_agent = dict[str, object](existing_agent)
diff --git a/litellm/proxy/agent_endpoints/managed_identity.py b/litellm/proxy/agent_endpoints/managed_identity.py
index 260b74fcbd1..abab21901ee 100644
--- a/litellm/proxy/agent_endpoints/managed_identity.py
+++ b/litellm/proxy/agent_endpoints/managed_identity.py
@@ -49,21 +49,12 @@ class IdentityHistoryKey(TypedDict):
client_id: ReadOnly[str]
-class IdentityHistoryWhere(TypedDict):
- provider_tenant_id_client_id: ReadOnly[IdentityHistoryKey]
-
-
class IdentityHistoryEntry(IdentityHistoryKey):
issuer: ReadOnly[str]
-class IdentityHistoryConnect(TypedDict):
- where: ReadOnly[IdentityHistoryWhere]
- create: ReadOnly[IdentityHistoryEntry]
-
-
class IdentityHistoryWrite(TypedDict):
- connectOrCreate: ReadOnly[IdentityHistoryConnect]
+ create: ReadOnly[IdentityHistoryEntry]
class ManagedWriteFields(TypedDict, total=False):
@@ -161,20 +152,11 @@ def _identity_write(identity: EntraIdentityConfig | None, existing: AgentRespons
}
result: Final[ManagedWriteFields] = {
"retired_identities": {
- "connectOrCreate": {
- "where": {
- "provider_tenant_id_client_id": {
- "provider": identity.provider,
- "tenant_id": identity.tenant_id,
- "client_id": identity.client_id,
- }
- },
- "create": {
- "provider": identity.provider,
- "issuer": identity.issuer,
- "tenant_id": identity.tenant_id,
- "client_id": identity.client_id,
- },
+ "create": {
+ "provider": identity.provider,
+ "issuer": identity.issuer,
+ "tenant_id": identity.tenant_id,
+ "client_id": identity.client_id,
}
},
"identity_managed": True,
diff --git a/litellm/types/agents.py b/litellm/types/agents.py
index 3b460bd66c6..94adb9f7c4a 100644
--- a/litellm/types/agents.py
+++ b/litellm/types/agents.py
@@ -10,6 +10,7 @@ from litellm.types.llms.base import LiteLLMPydanticObjectBase
from litellm.types.proxy.agent_identity import (
AgentExecutionMode,
AgentIdentityBinding,
+ EntraIdentityConfig,
)
if TYPE_CHECKING:
@@ -252,8 +253,11 @@ class AgentKillSwitchResult(BaseModel):
class AgentConfig(TypedDict, total=False):
+ identity: ReadOnly[EntraIdentityConfig | None]
+ enabled: ReadOnly[bool]
+ execution_mode: ReadOnly[AgentExecutionMode]
agent_name: Required[str]
- agent_card_params: Required[AgentCard]
+ agent_card_params: ReadOnly[AgentCard]
litellm_params: dict[str, object] # allow for any future litellm params
object_permission: AgentObjectPermission
tpm_limit: int | None
@@ -267,6 +271,9 @@ class AgentConfig(TypedDict, total=False):
class PatchAgentRequest(TypedDict, total=False):
+ identity: ReadOnly[EntraIdentityConfig | None]
+ enabled: ReadOnly[bool]
+ execution_mode: ReadOnly[AgentExecutionMode]
agent_name: str
agent_card_params: AgentCard
litellm_params: dict[str, object]
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py b/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py
index ef20e88c368..7663f1d30e6 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py
@@ -2,11 +2,14 @@
import hashlib
import json
+from collections.abc import Mapping
+from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
+from prisma.models import LiteLLM_AgentsTable
from litellm.constants import REDACTED_BY_LITELM_STRING
from litellm.proxy.agent_endpoints.agent_registry import (
@@ -451,11 +454,11 @@ async def test_update_agent_in_db_raises_when_row_deleted_mid_update():
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(litellm_params={}, object_permission_id=None, kill_switch=None)
+ return_value=_stored_agent_row(SimpleNamespace(litellm_params={}, object_permission_id=None))
)
mock_prisma.db.litellm_agentstable.update = AsyncMock(return_value=None)
- with pytest.raises(Exception, match="Error updating agent in DB") as exc_info:
+ with pytest.raises(Exception, match="Agent not found") as exc_info:
await registry.update_agent_in_db(
agent_id="agent-123",
agent={
@@ -467,7 +470,7 @@ async def test_update_agent_in_db_raises_when_row_deleted_mid_update():
updated_by="test-user",
)
- assert str(exc_info.value) == "Error updating agent in DB: Agent not found, passed agent_id=agent-123"
+ assert str(exc_info.value) == "Agent not found, passed agent_id=agent-123"
@pytest.mark.asyncio
@@ -476,11 +479,13 @@ async def test_patch_agent_in_db_raises_when_row_deleted_mid_update():
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={"agent_id": "agent-123", "agent_name": "Old Agent", "object_permission_id": None}
+ return_value=_stored_agent_row(
+ {"agent_id": "agent-123", "agent_name": "Old Agent", "object_permission_id": None}
+ )
)
mock_prisma.db.litellm_agentstable.update = AsyncMock(return_value=None)
- with pytest.raises(Exception, match="Error patching agent in DB") as exc_info:
+ with pytest.raises(Exception, match="Agent not found") as exc_info:
await registry.patch_agent_in_db(
agent_id="agent-123",
agent={"agent_name": "Patched Agent"},
@@ -488,20 +493,43 @@ async def test_patch_agent_in_db_raises_when_row_deleted_mid_update():
updated_by="test-user",
)
- assert str(exc_info.value) == "Error patching agent in DB: Agent not found, passed agent_id=agent-123"
+ assert str(exc_info.value) == "Agent not found, passed agent_id=agent-123"
@pytest.mark.asyncio
-async def test_delete_agent_from_db_raises_when_row_already_gone():
- """Prisma's delete returns None for a missing row, which dict() cannot consume."""
+async def test_delete_agent_from_db_raises_when_row_already_gone() -> None:
registry: Final = AgentRegistry()
- mock_prisma: Final = MagicMock()
- mock_prisma.db.litellm_agentstable.delete = AsyncMock(return_value=None)
+ database: Final = MagicMock()
+ tx: Final = database.tx.return_value.__aenter__.return_value
+ tx.litellm_agentstable.find_unique = AsyncMock(return_value=None)
+ with pytest.raises(ValueError, match="Agent not found, passed agent_id=agent-123"):
+ await registry.delete_agent_from_db(agent_id="agent-123", prisma_client=database)
+ tx.litellm_verificationtoken.delete_many.assert_not_called()
- with pytest.raises(Exception, match="Error deleting agent from DB") as exc_info:
- await registry.delete_agent_from_db(agent_id="agent-123", prisma_client=mock_prisma)
- assert str(exc_info.value) == "Error deleting agent from DB: Agent not found, passed agent_id=agent-123"
+@pytest.mark.asyncio
+@pytest.mark.parametrize("managed", [True, False])
+async def test_agent_deletion_revokes_managed_keys_and_keeps_identity_history(managed: bool) -> None:
+ registry: Final = AgentRegistry()
+ database: Final = MagicMock()
+ tx: Final = database.tx.return_value.__aenter__.return_value
+ row: Final = _stored_agent_row({"agent_id": "agent-123", "identity_managed": managed})
+ tx.litellm_agentstable.find_unique = AsyncMock(return_value=row)
+ tx.litellm_agentstable.delete = AsyncMock(return_value=row)
+ tx.litellm_verificationtoken.delete_many = AsyncMock(return_value=2)
+ tx.litellm_retiredagent.upsert = AsyncMock()
+ result: Final = await registry.delete_agent_from_db("agent-123", database)
+ assert result["agent_id"] == "agent-123"
+ tx.litellm_agentstable.delete.assert_awaited_once_with(where={"agent_id": "agent-123"})
+ if managed:
+ tx.litellm_retiredagent.upsert.assert_awaited_once_with(
+ where={"original_agent_id": "agent-123"},
+ data={"create": {"original_agent_id": "agent-123"}, "update": {}},
+ )
+ tx.litellm_verificationtoken.delete_many.assert_awaited_once_with(where={"agent_id": "agent-123"})
+ else:
+ tx.litellm_retiredagent.upsert.assert_not_awaited()
+ tx.litellm_verificationtoken.delete_many.assert_not_awaited()
# ---------- LIT-6736: agent litellm_params secret redaction ----------
@@ -729,14 +757,15 @@ async def test_update_agent_in_db_preserves_secret_when_echoed_back_redacted():
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(
- litellm_params={
- "aws_access_key_id": SENTINEL_AWS_ACCESS_KEY_ID,
- "aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY,
- "model": "bedrock/agentcore/my-agent",
- },
- object_permission_id=None,
- kill_switch=None,
+ return_value=_stored_agent_row(
+ SimpleNamespace(
+ litellm_params={
+ "aws_access_key_id": SENTINEL_AWS_ACCESS_KEY_ID,
+ "aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY,
+ "model": "bedrock/agentcore/my-agent",
+ },
+ object_permission_id=None,
+ )
)
)
updated_agent = MagicMock()
@@ -782,10 +811,11 @@ async def test_update_agent_in_db_preserves_secret_when_key_omitted_entirely():
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(
- litellm_params={"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
- object_permission_id=None,
- kill_switch=None,
+ return_value=_stored_agent_row(
+ SimpleNamespace(
+ litellm_params={"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
+ object_permission_id=None,
+ )
)
)
updated_agent = MagicMock()
@@ -824,15 +854,16 @@ async def test_update_agent_in_db_preserves_secret_nested_under_a_non_sensitive_
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(
- litellm_params={
- "provider_config": {
- "aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY,
- "region": "us-east-1",
- }
- },
- object_permission_id=None,
- kill_switch=None,
+ return_value=_stored_agent_row(
+ SimpleNamespace(
+ litellm_params={
+ "provider_config": {
+ "aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY,
+ "region": "us-east-1",
+ }
+ },
+ object_permission_id=None,
+ )
)
)
updated_agent = MagicMock()
@@ -878,10 +909,11 @@ async def test_update_agent_in_db_clears_secret_on_explicit_empty_value():
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(
- litellm_params={"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
- object_permission_id=None,
- kill_switch=None,
+ return_value=_stored_agent_row(
+ SimpleNamespace(
+ litellm_params={"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
+ object_permission_id=None,
+ )
)
)
updated_agent = MagicMock()
@@ -919,12 +951,14 @@ async def test_patch_agent_in_db_preserves_secret_when_litellm_params_omitted():
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Old Name",
- "litellm_params": {"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
- "object_permission_id": None,
- }
+ return_value=_stored_agent_row(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "Old Name",
+ "litellm_params": {"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
+ "object_permission_id": None,
+ }
+ )
)
patched_agent = MagicMock()
patched_agent.model_dump.return_value = {
@@ -958,15 +992,17 @@ async def test_patch_agent_in_db_preserves_secret_when_echoed_back_redacted():
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Test Agent",
- "litellm_params": {
- "aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY,
- "is_public": False,
- },
- "object_permission_id": None,
- }
+ return_value=_stored_agent_row(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "Test Agent",
+ "litellm_params": {
+ "aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY,
+ "is_public": False,
+ },
+ "object_permission_id": None,
+ }
+ )
)
patched_agent = MagicMock()
patched_agent.model_dump.return_value = {
@@ -997,6 +1033,48 @@ async def test_patch_agent_in_db_preserves_secret_when_echoed_back_redacted():
assert stored_params["is_public"] is True
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["patch", "put"])
+async def test_runtime_update_drops_legacy_identity_and_keeps_agent_id(operation: str) -> None:
+ registry: Final = AgentRegistry()
+ prisma: Final = MagicMock()
+ identity: Final = {
+ "provider": "microsoft_entra",
+ "tenant_id": "11111111-1111-4111-8111-111111111111",
+ "client_id": "22222222-2222-4222-8222-222222222222",
+ }
+ existing_params: Final = {"identity": identity, "model": "old"}
+ existing: Final = (
+ SimpleNamespace(litellm_params=existing_params, object_permission_id=None)
+ if operation == "put"
+ else {"agent_name": "Readable agent", "litellm_params": existing_params}
+ )
+ prisma.db.litellm_agentstable.find_unique = AsyncMock(return_value=_stored_agent_row(existing))
+ saved: Final = MagicMock()
+ saved.object_permission = None
+ saved.model_dump.return_value = {
+ "agent_id": "unchanged-id",
+ "agent_name": "Renamed agent",
+ "agent_card_params": {},
+ "litellm_params": {"model": "new"},
+ }
+ prisma.db.litellm_agentstable.update = AsyncMock(return_value=saved)
+ update: Final = registry.patch_agent_in_db if operation == "patch" else registry.update_agent_in_db
+ result: Final = await update(
+ agent_id="unchanged-id",
+ agent={"agent_name": "Renamed agent", "agent_card_params": {}, "litellm_params": {"model": "new"}},
+ prisma_client=prisma,
+ updated_by="admin",
+ )
+ stored: Final = prisma.db.litellm_agentstable.update.call_args.kwargs
+ assert stored["where"] == {"agent_id": "unchanged-id"}
+ assert json.loads(stored["data"]["litellm_params"]) == {"model": "new"}, (
+ "a stored litellm_params.identity must not be resurrected once the JWT path no longer honours it"
+ )
+ assert result.agent_id == "unchanged-id"
+ assert "object_permission_id" not in stored["data"]
+
+
def _agent_row_mock(access_group_ids: list[str]) -> MagicMock:
row: Final = MagicMock()
row.model_dump.return_value = {
@@ -1063,13 +1141,15 @@ async def test_patch_agent_in_db_replaces_access_group_ids_when_provided(
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Test Agent",
- "litellm_params": {},
- "object_permission_id": None,
- "access_group_ids": ["ag-1"],
- }
+ return_value=_stored_agent_row(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "Test Agent",
+ "litellm_params": {},
+ "object_permission_id": None,
+ "access_group_ids": ["ag-1"],
+ }
+ )
)
mock_update = AsyncMock(return_value=_agent_row_mock(expected))
mock_prisma.db.litellm_agentstable.update = mock_update
@@ -1086,13 +1166,15 @@ async def test_patch_agent_in_db_keeps_access_group_ids_when_omitted():
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Old Name",
- "litellm_params": {},
- "object_permission_id": None,
- "access_group_ids": ["ag-1"],
- }
+ return_value=_stored_agent_row(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "Old Name",
+ "litellm_params": {},
+ "object_permission_id": None,
+ "access_group_ids": ["ag-1"],
+ }
+ )
)
mock_update = AsyncMock(return_value=_agent_row_mock(["ag-1"]))
mock_prisma.db.litellm_agentstable.update = mock_update
@@ -1114,8 +1196,8 @@ async def test_update_agent_in_db_always_writes_access_group_ids(body_access_gro
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(
- litellm_params={}, object_permission_id=None, kill_switch=None, access_group_ids=["ag-1"]
+ return_value=_stored_agent_row(
+ SimpleNamespace(litellm_params={}, object_permission_id=None, access_group_ids=["ag-1"])
)
)
mock_update = AsyncMock(return_value=_agent_row_mock(expected))
@@ -1134,6 +1216,34 @@ async def test_update_agent_in_db_always_writes_access_group_ids(body_access_gro
assert tuple(mock_update.call_args.kwargs["data"]["access_group_ids"]) == tuple(expected)
+def _stored_agent_row(values: Mapping[str, object] | SimpleNamespace) -> LiteLLM_AgentsTable:
+ fields: Final = vars(values) if isinstance(values, SimpleNamespace) else values
+ return LiteLLM_AgentsTable.model_validate(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "Test Agent",
+ "agent_card_params": "{}",
+ "extra_headers": [],
+ "agent_access_groups": [],
+ "access_group_ids": [],
+ "created_at": datetime.now(timezone.utc),
+ "updated_at": datetime.now(timezone.utc),
+ "created_by": "admin",
+ "updated_by": "admin",
+ "spend": 0,
+ "identity_managed": False,
+ "enabled": True,
+ "execution_mode": "autonomous",
+ **{
+ key: json.dumps(value)
+ if key in ("litellm_params", "agent_card_params", "kill_switch", "static_headers") and not isinstance(value, str)
+ else value
+ for key, value in fields.items()
+ },
+ }
+ )
+
+
_KILL_SWITCH: Final = {
"url": "https://ops.example.com/kill",
"method": "POST",
@@ -1194,13 +1304,15 @@ async def test_patch_agent_in_db_keeps_kill_switch_when_omitted_and_clears_it_on
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Old",
- "litellm_params": {},
- "object_permission_id": None,
- "kill_switch": _KILL_SWITCH,
- }
+ return_value=_stored_agent_row(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "Old",
+ "litellm_params": {},
+ "object_permission_id": None,
+ "kill_switch": _KILL_SWITCH,
+ }
+ )
)
mock_update = AsyncMock(return_value=_agent_row_mock([]))
mock_prisma.db.litellm_agentstable.update = mock_update
@@ -1223,13 +1335,15 @@ async def test_patch_agent_in_db_restores_the_stored_kill_switch_secret_behind_t
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "A",
- "litellm_params": {},
- "object_permission_id": None,
- "kill_switch": _KILL_SWITCH,
- }
+ return_value=_stored_agent_row(
+ {
+ "agent_id": "agent-123",
+ "agent_name": "A",
+ "litellm_params": {},
+ "object_permission_id": None,
+ "kill_switch": _KILL_SWITCH,
+ }
+ )
)
mock_update = AsyncMock(return_value=_agent_row_mock([]))
mock_prisma.db.litellm_agentstable.update = mock_update
@@ -1258,7 +1372,9 @@ async def test_update_agent_in_db_clears_kill_switch_when_omitted_and_restores_s
registry: Final = AgentRegistry()
mock_prisma: Final = MagicMock()
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=SimpleNamespace(litellm_params={}, object_permission_id=None, kill_switch=json.dumps(_KILL_SWITCH))
+ return_value=_stored_agent_row(
+ SimpleNamespace(litellm_params={}, object_permission_id=None, kill_switch=json.dumps(_KILL_SWITCH))
+ )
)
mock_update = AsyncMock(return_value=_agent_row_mock([]))
mock_prisma.db.litellm_agentstable.update = mock_update
@@ -1284,3 +1400,234 @@ def test_load_agents_from_config_exposes_a_typed_kill_switch():
(agent,) = registry.get_agent_list()
assert agent.kill_switch is not None
assert agent.kill_switch.model_dump() == _KILL_SWITCH
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("bound", [False, True])
+async def test_agent_listing_preserves_stored_identity_bindings(bound: bool) -> None:
+ from datetime import datetime, timezone
+
+ from prisma.models import LiteLLM_AgentIdentity, LiteLLM_AgentsTable
+
+ from litellm.types.agents import AgentResponse
+
+ binding: Final = LiteLLM_AgentIdentity(
+ agent_id="agent",
+ provider="microsoft_entra",
+ issuer="issuer",
+ tenant_id="tenant",
+ client_id="client",
+ active=True,
+ required_roles=[],
+ required_scopes=["user_impersonation"],
+ revision="revision",
+ )
+ row: Final = LiteLLM_AgentsTable(
+ agent_id="agent",
+ agent_name="Bound agent",
+ agent_card_params="{}",
+ identity_managed=bound,
+ identity=binding if bound else None,
+ enabled=True,
+ execution_mode="autonomous",
+ spend=0.0,
+ agent_access_groups=[],
+ access_group_ids=[],
+ extra_headers=[],
+ created_by="admin",
+ updated_by="admin",
+ created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ )
+ client: Final = MagicMock()
+ client.db.litellm_agentstable.find_many = AsyncMock(return_value=[row])
+ listed: Final = await AgentRegistry.get_all_agents_from_db(client)
+ response: Final = AgentResponse.model_validate(listed[0])
+ if bound:
+ assert response.identity is not None
+ assert response.identity.client_id == binding.client_id
+ assert response.identity.revision == binding.revision
+ else:
+ assert response.identity is None
+ client.db.litellm_agentstable.find_many.assert_awaited_once_with(
+ order={"created_at": "desc"},
+ include={"object_permission": True, "identity": True},
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["create", "patch", "put"])
+async def test_agent_permissions_are_written_atomically_with_the_registration(operation: str) -> None:
+ from litellm.proxy._types import LiteLLM_ObjectPermissionTable
+
+ registry: Final = AgentRegistry()
+ client: Final = MagicMock()
+ existing: Final = _stored_agent_row({"agent_id": "agent-123", "object_permission_id": "permissions"})
+ client.db.litellm_agentstable.find_unique = AsyncMock(return_value=existing)
+ client.db.litellm_agentstable.create = AsyncMock(return_value=existing)
+ client.db.litellm_agentstable.update = AsyncMock(return_value=existing)
+ client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
+ return_value=(
+ LiteLLM_ObjectPermissionTable(object_permission_id="permissions", models=["prior"], mcp_servers=["slack"])
+ if operation != "create"
+ else None
+ )
+ )
+ incoming: Final = {"agent_name": "Agent", "agent_card_params": {}, "object_permission": {"models": ["new"]}}
+ if operation == "create":
+ await registry.add_agent_to_db(incoming, client, created_by="admin")
+ else:
+ update: Final = registry.patch_agent_in_db if operation == "patch" else registry.update_agent_in_db
+ await update("agent-123", incoming, client, updated_by="admin")
+ write: Final = (
+ client.db.litellm_agentstable.create if operation == "create" else client.db.litellm_agentstable.update
+ )
+ permission: Final = write.call_args.kwargs["data"]["object_permission"][
+ "create" if operation == "create" else "update"
+ ]
+ assert permission["models"] == ["new"]
+ if operation != "create":
+ assert permission["mcp_servers"] == ["slack"]
+ assert permission["object_permission_id"] == "permissions"
+ client.db.litellm_objectpermissiontable.update.assert_not_called()
+ client.db.litellm_objectpermissiontable.create.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["create", "patch", "put"])
+async def test_invalid_identity_fails_before_registration_is_written(operation: str) -> None:
+ from fastapi import HTTPException
+
+ registry: Final = AgentRegistry()
+ client: Final = MagicMock()
+ client.db.litellm_agentstable.create = AsyncMock()
+ client.db.litellm_agentstable.update = AsyncMock()
+ client.db.litellm_agentstable.find_unique = AsyncMock(return_value=_stored_agent_row({"agent_id": "agent-123"}))
+ incoming: Final = {"agent_name": "Agent", "agent_card_params": {}, "identity": {"provider": "unknown"}}
+ write: Final = (
+ registry.add_agent_to_db(incoming, client, created_by="admin")
+ if operation == "create"
+ else (registry.patch_agent_in_db if operation == "patch" else registry.update_agent_in_db)(
+ "agent-123", incoming, client, updated_by="admin"
+ )
+ )
+ with pytest.raises(HTTPException) as failure:
+ await write
+ assert failure.value.status_code == 400
+ client.db.litellm_agentstable.create.assert_not_awaited()
+ client.db.litellm_agentstable.update.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["create", "patch", "put"])
+async def test_duplicate_agent_binding_returns_conflict_for_every_write(operation: str) -> None:
+ from fastapi import HTTPException
+ from prisma.errors import UniqueViolationError
+
+ registry: Final = AgentRegistry()
+ client: Final = MagicMock()
+ client.db.litellm_agentstable.find_unique = AsyncMock(return_value=_stored_agent_row({"agent_id": "agent-123"}))
+ failure: Final = UniqueViolationError(
+ {
+ "user_facing_error": {
+ "message": "Unique constraint failed",
+ "meta": {"target": ["client_id"]},
+ "error_code": "P2002",
+ }
+ }
+ )
+ client.db.litellm_agentstable.create = AsyncMock(side_effect=failure)
+ client.db.litellm_agentstable.update = AsyncMock(side_effect=failure)
+ incoming: Final = {"agent_name": "Agent", "agent_card_params": {}}
+ write: Final = (
+ registry.add_agent_to_db(incoming, client, created_by="admin")
+ if operation == "create"
+ else (registry.patch_agent_in_db if operation == "patch" else registry.update_agent_in_db)(
+ "agent-123", incoming, client, updated_by="admin"
+ )
+ )
+ with pytest.raises(HTTPException) as denied:
+ await write
+ assert denied.value.status_code == 409
+ assert denied.value.detail == "Agent name or Entra application is already registered"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["create", "patch", "put"])
+@pytest.mark.parametrize("owner", ["previous-agent", None])
+async def test_retired_application_cannot_transfer_to_another_agent(operation: str, owner: str | None) -> None:
+ from fastapi import HTTPException
+
+ registry: Final = AgentRegistry()
+ client: Final = MagicMock()
+ row: Final = _stored_agent_row({"agent_id": "agent-123"})
+ client.db.litellm_agentstable.find_unique = AsyncMock(return_value=row)
+ client.db.litellm_agentstable.create = AsyncMock(return_value=row)
+ client.db.litellm_agentstable.update = AsyncMock(return_value=row)
+ client.writer_db.litellm_retiredagentidentity.find_unique = AsyncMock(return_value=SimpleNamespace(agent_id=owner))
+ incoming: Final = {
+ "agent_name": "Agent",
+ "agent_card_params": {},
+ "identity": {
+ "provider": "microsoft_entra",
+ "tenant_id": "11111111-1111-4111-8111-111111111111",
+ "client_id": "22222222-2222-4222-8222-222222222222",
+ "service_principal_id": "33333333-3333-4333-8333-333333333333",
+ },
+ }
+ write: Final = (
+ registry.add_agent_to_db(incoming, client, created_by="admin")
+ if operation == "create"
+ else (registry.patch_agent_in_db if operation == "patch" else registry.update_agent_in_db)(
+ "agent-123", incoming, client, updated_by="admin"
+ )
+ )
+ with pytest.raises(HTTPException) as denied:
+ await write
+ assert denied.value.status_code == 409
+ client.db.litellm_agentstable.create.assert_not_awaited()
+ client.db.litellm_agentstable.update.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("operation", ["create", "patch", "put"])
+@pytest.mark.parametrize("prior_owner", [False, True])
+async def test_application_registration_preserves_its_existing_owner(operation: str, prior_owner: bool) -> None:
+ registry: Final = AgentRegistry()
+ client: Final = MagicMock()
+ row: Final = _stored_agent_row({"agent_id": "agent-123"})
+ client.db.litellm_agentstable.find_unique = AsyncMock(return_value=row)
+ client.db.litellm_agentstable.create = AsyncMock(return_value=row)
+ client.db.litellm_agentstable.update = AsyncMock(return_value=row)
+ client.writer_db.litellm_retiredagentidentity.find_unique = AsyncMock(
+ return_value=SimpleNamespace(agent_id="agent-123") if prior_owner and operation != "create" else None
+ )
+ incoming: Final = {
+ "agent_name": "Agent",
+ "agent_card_params": {},
+ "identity": {
+ "provider": "microsoft_entra",
+ "tenant_id": "11111111-1111-4111-8111-111111111111",
+ "client_id": "22222222-2222-4222-8222-222222222222",
+ "service_principal_id": "33333333-3333-4333-8333-333333333333",
+ },
+ }
+ if operation == "create":
+ result: Final = await registry.add_agent_to_db(incoming, client, created_by="admin")
+ else:
+ update: Final = registry.patch_agent_in_db if operation == "patch" else registry.update_agent_in_db
+ result = await update("agent-123", incoming, client, updated_by="admin")
+ assert result.agent_id == "agent-123"
+ write: Final = (
+ client.db.litellm_agentstable.create if operation == "create" else client.db.litellm_agentstable.update
+ )
+ data: Final = write.call_args.kwargs["data"]
+ if prior_owner and operation != "create":
+ assert "retired_identities" not in data
+ else:
+ assert data["retired_identities"] == {
+ "create": {
+ **{key: value for key, value in incoming["identity"].items() if key != "service_principal_id"},
+ "issuer": "https://login.microsoftonline.com/11111111-1111-4111-8111-111111111111/v2.0",
+ }
+ }
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py
index bd43cb7ce13..2cf81892db7 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py
@@ -1,12 +1,16 @@
import json
+from collections.abc import Mapping
+from datetime import datetime, timezone
+
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
-from fastapi import FastAPI
+from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
+from prisma.models import LiteLLM_AgentsTable
from litellm.constants import REDACTED_BY_LITELM_STRING
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth
@@ -21,7 +25,8 @@ from litellm.proxy.agent_endpoints.endpoints import (
router,
user_api_key_auth,
)
-from litellm.types.agents import AgentResponse
+from litellm.types.agents import AgentResponse, PatchAgentRequest
+from litellm.types.proxy.agent_identity import AgentIdentityBinding
def _sample_agent_card_params() -> dict:
@@ -97,7 +102,7 @@ def test_update_agent_success(mock_prisma_client, mock_user_api_key_auth, monkey
"agent_card_params": _sample_agent_card_params(),
}
mock_prisma_client.db.litellm_agentstable.find_unique = AsyncMock(
- return_value=existing_agent
+ return_value=AgentResponse.model_validate(existing_agent)
)
mock_registry = MagicMock()
@@ -137,6 +142,61 @@ def test_update_agent_not_found(
assert "Agent with ID missing-agent not found" in response.json()["detail"]
+class _AgentPersistence:
+ def __init__(self, row: LiteLLM_AgentsTable) -> None:
+ self.row = row
+
+ async def find_unique(self, **kwargs: object) -> LiteLLM_AgentsTable:
+ return self.row
+
+ async def update(self, *, data: Mapping[str, object], **kwargs: object) -> LiteLLM_AgentsTable:
+ from tests.test_litellm.proxy.agent_endpoints.test_agent_registry import _stored_agent_row
+
+ self.row = _stored_agent_row({**self.row.model_dump(), **data})
+ return self.row
+
+
+@pytest.mark.parametrize("method", ["PUT", "PATCH"])
+@pytest.mark.parametrize("cardless", [False, True])
+def test_identity_settings_edit_preserves_runtime_configuration_on_readback(
+ monkeypatch: pytest.MonkeyPatch, method: str, cardless: bool
+) -> None:
+ from litellm.proxy import proxy_server
+ from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry
+ from tests.test_litellm.proxy.agent_endpoints.test_agent_registry import _stored_agent_row
+
+ runtime: Final = {
+ "agent_card_params": {} if cardless else _sample_agent_card_params(),
+ "litellm_params": {"make_public": False, "model": "a2a/runtime"},
+ "static_headers": {"X-Runtime": "configured"},
+ "extra_headers": ["X-Trace"],
+ "access_group_ids": ["runtime-group"],
+ "kill_switch": {"url": "https://runtime.example/stop", "method": "POST"},
+ }
+ row: Final = _stored_agent_row(runtime)
+ table: Final = _AgentPersistence(row)
+ database: Final = SimpleNamespace(
+ litellm_agentstable=table,
+ litellm_verificationtoken=SimpleNamespace(find_many=AsyncMock(return_value=[])),
+ )
+ monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=database, writer_db=database))
+ monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", AgentRegistry())
+
+ response: Final = client.request(
+ method, "/v1/agents/agent-123", json={"agent_name": "Renamed agent", "enabled": False}
+ )
+ assert response.status_code == 200, response.text
+ readback: Final = client.get("/v1/agents/agent-123")
+ assert readback.status_code == 200, readback.text
+ stored: Final = AgentResponse.model_validate(table.row.model_dump())
+ expected: Final = AgentResponse.model_validate(row.model_dump()).model_copy(
+ update={"agent_name": "Renamed agent", "enabled": False}
+ )
+ preserved: Final = {*runtime, "agent_name", "enabled", "agent_id"}
+ assert stored.model_dump(include=preserved) == expected.model_dump(include=preserved)
+ assert {key: readback.json()[key] for key in preserved} == expected.model_dump(mode="json", include=preserved)
+
+
def test_get_agent_by_id_not_found(
mock_prisma_client, mock_user_api_key_auth, monkeypatch
):
@@ -350,7 +410,7 @@ class TestAgentByIdKeyRedaction:
test_client = _make_app_with_role(role)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
- mock_prisma.writer_db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
+ mock_prisma.writer_db = mock_prisma.db
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
return_value=None
)
@@ -413,7 +473,7 @@ class TestAgentRBACInternalUser:
return_value=_sample_agent_response()
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
- mock_prisma.writer_db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
+ mock_prisma.writer_db = mock_prisma.db
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
return_value=None
)
@@ -594,6 +654,24 @@ class TestAgentRBACProxyAdmin:
)
assert resp.status_code == 200
+ def test_create_agent_rejects_legacy_litellm_params_identity(self):
+ with patch("litellm.proxy.proxy_server.prisma_client"): # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ self.mock_registry.get_agent_by_name = MagicMock(return_value=None)
+ self.mock_registry.add_agent_to_db = AsyncMock(return_value=_sample_agent_response())
+ config = _sample_agent_config()
+ config["litellm_params"] = {
+ **config["litellm_params"],
+ "identity": {
+ "provider": "microsoft_entra",
+ "tenant_id": "11111111-1111-4111-8111-111111111111",
+ "client_id": "22222222-2222-4222-8222-222222222222",
+ },
+ }
+ resp = self.admin_client.post("/v1/agents", json=config, headers={"Authorization": "Bearer k"})
+ assert resp.status_code == 400, resp.text
+ assert "top-level identity field" in resp.json()["detail"]
+ self.mock_registry.add_agent_to_db.assert_not_awaited()
+
def test_create_agent_applies_litellm_merge_to_stored_card(self):
"""The card stored in the DB must reflect the LiteLLM-fronting merge."""
with patch("litellm.proxy.proxy_server.prisma_client"):
@@ -665,11 +743,9 @@ class TestAgentRBACProxyAdmin:
"""LIT-6736: PUT /v1/agents/{id} must not echo the stored secret back."""
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: # test-quality-ok: proxy_server module global is the endpoint's only injection point
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Existing Agent",
- "agent_card_params": _sample_agent_card_params(),
- }
+ return_value=AgentResponse(
+ agent_id="agent-123", agent_name="Existing Agent", agent_card_params=_sample_agent_card_params()
+ )
)
self.mock_registry.update_agent_in_db = AsyncMock(
return_value=AgentResponse(
@@ -700,11 +776,9 @@ class TestAgentRBACProxyAdmin:
"""LIT-6736: PATCH /v1/agents/{id} must not echo the stored secret back."""
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: # test-quality-ok: proxy_server module global is the endpoint's only injection point
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
- return_value={
- "agent_id": "agent-123",
- "agent_name": "Existing Agent",
- "agent_card_params": _sample_agent_card_params(),
- }
+ return_value=AgentResponse(
+ agent_id="agent-123", agent_name="Existing Agent", agent_card_params=_sample_agent_card_params()
+ )
)
self.mock_registry.patch_agent_in_db = AsyncMock(
return_value=AgentResponse(
@@ -1142,6 +1216,143 @@ def test_make_agent_public_rejects_an_agent_published_only_in_the_db(monkeypatch
assert "already in public agent groups" in duplicate.json()["detail"]
+@pytest.mark.parametrize("enabled, claim_field, expected", [(True, "azp", True), (False, "azp", False), (True, None, False)])
+def test_jwt_authentication_status_does_not_require_virtual_keys(
+ monkeypatch: pytest.MonkeyPatch, enabled: bool, claim_field: str | None, expected: bool
+) -> None:
+ from litellm.caching.dual_cache import DualCache
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import LiteLLM_JWTAuth
+ from litellm.proxy.auth.handle_jwt import JWTHandler
+
+ handler: Final = JWTHandler()
+ handler.update_environment(None, DualCache(), LiteLLM_JWTAuth(agent_id_jwt_field=claim_field))
+ monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": enabled})
+ monkeypatch.setattr(proxy_server, "jwt_handler", handler)
+ agent: Final = _sample_agent_response()
+ response: Final = agent_endpoints._redact_sensitive_agent_fields((agent,), is_admin=True)[0]
+ assert response.jwt_auth_configured is expected
+ assert agent.jwt_auth_configured is False
+
+
+def test_identity_providers_require_configured_issuer_and_audience(monkeypatch: pytest.MonkeyPatch) -> None:
+ from litellm.caching.dual_cache import DualCache
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import LiteLLM_JWTAuth
+ from litellm.proxy.auth.handle_jwt import JWTHandler
+
+ handler: Final = JWTHandler()
+ handler.update_environment(None, DualCache(), LiteLLM_JWTAuth())
+ monkeypatch.setattr(proxy_server, "jwt_handler", handler)
+ monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": True})
+ monkeypatch.setenv("JWT_ISSUER", "https://issuer.example")
+ monkeypatch.delenv("JWT_AUDIENCE", raising=False)
+ assert client.get("/v1/agents/identity/providers").json() == []
+ monkeypatch.setenv("JWT_AUDIENCE", "gateway")
+ response: Final = client.get("/v1/agents/identity/providers")
+ assert response.status_code == 200
+ assert response.json() == ["https://issuer.example"]
+ forbidden: Final = _make_app_with_role(LitellmUserRoles.INTERNAL_USER).get("/v1/agents/identity/providers")
+ assert forbidden.status_code == 403
+
+
+def test_identity_evidence_is_persisted_and_never_taken_from_runtime_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
+ from litellm.proxy import proxy_server
+ from litellm.types.proxy.agent_identity import AgentIdentityBinding
+
+ binding: Final = AgentIdentityBinding(
+ agent_id="bound",
+ provider="microsoft_entra",
+ tenant_id="11111111-1111-4111-8111-111111111111",
+ client_id="22222222-2222-4222-8222-222222222222",
+ issuer="https://issuer.example",
+ revision="revision-one",
+ )
+ bound: Final = AgentResponse(
+ agent_id="bound",
+ agent_name="Readable name",
+ agent_card_params={},
+ identity=binding,
+ identity_managed=True,
+ litellm_params={"last_authenticated_at": "forged-proof"},
+ )
+ database: Final = MagicMock()
+ database.writer_db.litellm_agentstable.find_unique = AsyncMock(return_value=bound)
+ monkeypatch.setattr(proxy_server, "prisma_client", database)
+ pending: Final = client.get("/v1/agents/bound/identity")
+ assert pending.status_code == 200
+ assert pending.json()["last_authenticated_at"] is None
+ verified_binding: Final = binding.model_copy(
+ update={"last_authenticated_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}
+ )
+ database.writer_db.litellm_agentstable.find_unique.return_value = bound.model_copy(update={"identity": verified_binding})
+ verified: Final = client.get("/v1/agents/bound/identity")
+ assert verified.json()["last_authenticated_at"] == "2026-01-01T00:00:00Z"
+ assert verified.json()["identity"]["client_id"] == binding.client_id
+ database.writer_db.litellm_agentstable.find_unique.return_value = None
+ assert client.get("/v1/agents/missing/identity").status_code == 404
+ database.writer_db.litellm_agentstable.find_unique.side_effect = RuntimeError("unavailable")
+ assert client.get("/v1/agents/bound/identity").status_code == 503
+
+
+@pytest.mark.parametrize("enabled", [True, False])
+def test_identity_providers_honor_issuer_specific_audiences_and_global_fallback(
+ monkeypatch: pytest.MonkeyPatch, enabled: bool
+) -> None:
+ from litellm.caching.dual_cache import DualCache
+ from litellm.proxy import proxy_server
+ from litellm.proxy._types import JWTIssuerConfig, LiteLLM_JWTAuth
+ from litellm.proxy.auth.handle_jwt import JWTHandler
+
+ handler: Final = JWTHandler()
+ handler.update_environment(
+ None,
+ DualCache(),
+ LiteLLM_JWTAuth(
+ issuers=[
+ JWTIssuerConfig(issuer="https://scoped.example", audience="gateway"),
+ JWTIssuerConfig(issuer="https://unscoped.example", disable_audience_validation=True),
+ ]
+ ),
+ )
+ monkeypatch.setattr(proxy_server, "jwt_handler", handler)
+ monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": enabled})
+ monkeypatch.setenv("JWT_ISSUER", "https://global.example")
+ monkeypatch.setenv("JWT_AUDIENCE", "gateway")
+ assert client.get("/v1/agents/identity/providers").json() == (
+ ["https://scoped.example", "https://global.example"] if enabled else []
+ )
+ monkeypatch.setenv("JWT_ISSUER", "https://unscoped.example")
+ assert client.get("/v1/agents/identity/providers").json() == (["https://scoped.example"] if enabled else [])
+
+
+@pytest.mark.parametrize("change", ({"execution_mode": "delegated"}, {"execution_mode": "both"}))
+def test_mode_only_edit_requires_the_existing_identity_sso_tenant(
+ monkeypatch: pytest.MonkeyPatch, change: PatchAgentRequest
+) -> None:
+ from tests.test_litellm.proxy.agent_endpoints.test_managed_identity import BINDING, TENANT, managed_agent
+
+ monkeypatch.setattr(agent_endpoints, "_trusted_agent_issuers", lambda: (BINDING.issuer,))
+ monkeypatch.delenv("MICROSOFT_TENANT", raising=False)
+ monkeypatch.setenv("MICROSOFT_CLIENT_ID", "gateway-client")
+ with pytest.raises(HTTPException, match="Delegated agents require Microsoft SSO"):
+ agent_endpoints._validate_managed_identity_request(change, managed_agent())
+ monkeypatch.setenv("MICROSOFT_TENANT", TENANT)
+ agent_endpoints._validate_managed_identity_request(change, managed_agent())
+
+
+def test_identity_only_edit_preserves_delegated_mode_validation(monkeypatch: pytest.MonkeyPatch) -> None:
+ from tests.test_litellm.proxy.agent_endpoints.test_managed_identity import BINDING, managed_agent
+
+ monkeypatch.setattr(agent_endpoints, "_trusted_agent_issuers", lambda: (BINDING.issuer,))
+ monkeypatch.delenv("MICROSOFT_TENANT", raising=False)
+ configuration: Final = BINDING.model_dump(
+ exclude={"agent_id", "issuer", "revision", "last_authenticated_at", "active"}
+ )
+ delegated: Final = managed_agent().model_copy(update={"execution_mode": "delegated"})
+ with pytest.raises(HTTPException, match="Delegated agents require Microsoft SSO"):
+ agent_endpoints._validate_managed_identity_request({"identity": configuration}, delegated)
+
_KILL_SWITCH: Final = {
"url": "https://ops.example.com/kill",
"method": "POST",
@@ -1344,7 +1555,7 @@ def test_get_agent_redacts_kill_switch_secret_for_admins_and_hides_it_from_other
def _get_as(role: LitellmUserRoles):
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
- mock_prisma.writer_db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
+ mock_prisma.writer_db = mock_prisma.db
mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
return _make_app_with_role(role).get("/v1/agents/agent-123", headers={"Authorization": "Bearer k"})
@@ -1360,3 +1571,80 @@ def test_get_agent_redacts_kill_switch_secret_for_admins_and_hides_it_from_other
assert internal.status_code == 200, internal.text
assert internal.json()["kill_switch"] is None
assert "tok-real" not in internal.text
+
+
+@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+@pytest.mark.parametrize("path", ["/v1/agents", "/v1/agents/agent-123"])
+def test_agent_identity_configuration_is_only_returned_to_admins(role, path, monkeypatch):
+ from litellm.proxy.agent_endpoints import agent_registry
+
+ binding = AgentIdentityBinding(
+ agent_id="agent-123", provider="microsoft_entra", tenant_id="tenant", client_id="client",
+ issuer="https://login.microsoftonline.com/tenant/v2.0", revision="revision",
+ )
+ agent = _sample_agent_response().model_copy(update={"identity": binding})
+ registry = MagicMock()
+ registry.get_agent_by_id.return_value = agent
+ registry.get_agent_list.return_value = [agent]
+ registry.ids_for_agent.return_value = frozenset({agent.agent_id})
+ monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
+ monkeypatch.setattr(agent_registry, "global_agent_registry", registry)
+ monkeypatch.setattr(
+ "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.resolve_agent_access",
+ AsyncMock(return_value=RestrictedAgentAccess(frozenset({agent.agent_id}))),
+ )
+ with patch("litellm.proxy.proxy_server.prisma_client") as prisma:
+ prisma.db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
+ prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[])
+ prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
+ prisma.writer_db.litellm_agentstable.find_unique = AsyncMock(return_value=None)
+ response = _make_app_with_role(role).get(path, headers={"Authorization": "Bearer k"})
+ assert response.status_code == 200
+ payload = response.json()[0] if path == "/v1/agents" else response.json()
+ assert payload["identity"] == (binding.model_dump(mode="json") if role == LitellmUserRoles.PROXY_ADMIN else None)
+ assert agent.identity == binding
+
+
+@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.INTERNAL_USER])
+def test_agent_detail_cache_miss_preserves_admin_identity_visibility(role, monkeypatch):
+ binding = AgentIdentityBinding(
+ agent_id="agent-123", provider="microsoft_entra", tenant_id="tenant", client_id="client",
+ issuer="https://login.microsoftonline.com/tenant/v2.0", revision="revision",
+ )
+ agent = _sample_agent_response()
+ registry = MagicMock()
+ registry.get_agent_by_id.return_value = None
+ registry.ids_for_agent.return_value = frozenset({agent.agent_id})
+ monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
+ monkeypatch.setattr(
+ "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed",
+ AsyncMock(return_value=True),
+ )
+
+ async def load_row(*, where, include):
+ assert where == {"agent_id": agent.agent_id}
+ return agent.model_copy(update={"identity": binding if include.get("identity") else None})
+
+ with patch("litellm.proxy.proxy_server.prisma_client") as prisma:
+ prisma.db.litellm_agentstable.find_unique = AsyncMock(side_effect=load_row)
+ prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
+ response = _make_app_with_role(role).get("/v1/agents/agent-123")
+ assert response.status_code == 200
+ assert response.json()["identity"] == (binding.model_dump(mode="json") if role == LitellmUserRoles.PROXY_ADMIN else None)
+
+
+@pytest.mark.parametrize("trusted", [False, True])
+def test_invalid_identity_and_untrusted_tenant_cannot_be_registered(
+ monkeypatch: pytest.MonkeyPatch, trusted: bool
+) -> None:
+ from tests.test_litellm.proxy.agent_endpoints.test_managed_identity import BINDING
+
+ configuration: Final = BINDING.model_dump(
+ exclude={"agent_id", "issuer", "revision", "last_authenticated_at", "active"}
+ )
+ monkeypatch.setattr(agent_endpoints, "_trusted_agent_issuers", lambda: (BINDING.issuer,) if trusted else ())
+ request: Final = {"identity": {**configuration, "client_id": "invalid"} if trusted else configuration}
+ message: Final = "Invalid Entra identity configuration" if trusted else "Configure trusted JWT issuer"
+ with pytest.raises(HTTPException, match=message) as failure:
+ agent_endpoints._validate_managed_identity_request(request)
+ assert failure.value.status_code == 400
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_managed_identity.py b/tests/test_litellm/proxy/agent_endpoints/test_managed_identity.py
index 45fe4b0655f..17f3cdb52f5 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_managed_identity.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_managed_identity.py
@@ -162,12 +162,12 @@ def test_each_application_binding_records_its_history_atomically() -> None:
)
created: Final = managed_write_fields({"identity": configuration}, None, "admin")
assert not isinstance(created, AgentIdentityFailure)
- assert created["retired_identities"]["connectOrCreate"]["create"]["client_id"] == CLIENT
+ assert created["retired_identities"]["create"]["client_id"] == CLIENT
replacement: Final = managed_write_fields(
{"identity": {**configuration, "client_id": HUMAN}}, managed_agent(), "admin"
)
assert not isinstance(replacement, AgentIdentityFailure)
- assert replacement["retired_identities"]["connectOrCreate"]["create"]["client_id"] == HUMAN
+ assert replacement["retired_identities"]["create"]["client_id"] == HUMAN
def test_unchanged_binding_preserves_revision_and_authentication_evidence() -> None:
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx
new file mode 100644
index 00000000000..ce54ab78d9c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx
@@ -0,0 +1,43 @@
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
+import { apiClient } from "@/components/networking";
+import { AgentIdentityDetails } from "./AgentIdentityDetails";
+
+vi.mock("@/components/networking", () => ({ apiClient: { get: vi.fn() } }));
+
+const identity = {
+ provider: "microsoft_entra",
+ tenant_id: "11111111-1111-4111-8111-111111111111",
+ client_id: "22222222-2222-4222-8222-222222222222",
+};
+
+const status = {
+ enabled: true,
+ execution_mode: "autonomous",
+ last_authenticated_at: "2026-09-24T12:00:00Z",
+};
+
+describe("agent identity evidence", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ testQueryClient.clear();
+ });
+
+ it("shows persisted application identity evidence and links to the current logs route", async () => {
+ vi.mocked(apiClient.get).mockResolvedValue(status);
+ renderWithProviders( );
+ expect(await screen.findByText(/Last authenticated identity match:/)).toBeInTheDocument();
+ expect(screen.getByText(/Application \(Client\) ID:/)).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "View request logs" })).toHaveAttribute("href", "/ui/logs/");
+ expect(apiClient.get).toHaveBeenCalledWith("/v1/agents/native/identity", { accessToken: "admin" });
+ });
+
+ it("does not request or show administrator identity evidence to ordinary users", () => {
+ renderWithProviders(
+ ,
+ );
+ expect(screen.queryByRole("region", { name: "Agent Identity" })).not.toBeInTheDocument();
+ expect(apiClient.get).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx
new file mode 100644
index 00000000000..12465c6e861
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx
@@ -0,0 +1,81 @@
+import React from "react";
+import type { components } from "@/lib/http/schema";
+import { useQuery } from "@tanstack/react-query";
+import { apiClient } from "@/components/networking";
+import { Button } from "@/components/ui/button";
+import { readAgentIdentity } from "./agent_identity";
+
+const authenticationMessage = (error: boolean, lastAuthenticated?: string | null): string => {
+ if (error) return "Could not load authentication evidence";
+ if (lastAuthenticated) return `Last authenticated identity match: ${new Date(lastAuthenticated).toLocaleString()}`;
+ return "Configured, awaiting an authenticated request";
+};
+
+export const AgentIdentityDetails = ({
+ agentId,
+ identity: value,
+ accessToken,
+ isAdmin,
+}: {
+ agentId: string;
+ identity: unknown;
+ accessToken: string | null;
+ isAdmin: boolean;
+}) => {
+ const identity = readAgentIdentity(value);
+ const { data, isError, isFetching, refetch } = useQuery({
+ queryKey: ["agent-identity", agentId, identity],
+ queryFn: () =>
+ apiClient.get(
+ `/v1/agents/${encodeURIComponent(agentId)}/identity`,
+ {
+ accessToken: accessToken ?? "",
+ },
+ ),
+ enabled: Boolean(isAdmin && accessToken && identity),
+ });
+
+ if (!identity || !isAdmin) return null;
+ const executionLabel = data?.enabled ? "Enabled" : "Disabled";
+ return (
+
+ Agent Identity: Microsoft Entra ID
+
+ Tenant: {identity.tenant_id}
+
+ <>
+
+ Application (Client) ID: {identity.client_id}
+
+ Enterprise application Object ID: {identity.service_principal_id || "Not configured"}
+ >
+
+ Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
+
+
+ {data?.identity?.active === false
+ ? "Identity unbound; execution is disabled"
+ : authenticationMessage(isError, data?.last_authenticated_at)}
+
+
+ Recent evidence comes from a validated Entra token matching this binding. It is persisted across restarts and
+ cleared when the binding changes. Tool and model permissions are checked separately.
+
+
+
+ );
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx
new file mode 100644
index 00000000000..50c60776ff3
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx
@@ -0,0 +1,261 @@
+import React, { useEffect, useState } from "react";
+import { useWatch } from "react-hook-form";
+import { apiClient } from "@/components/networking";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { AgentFormField, type AgentFormValues } from "./AgentFormKit";
+import { entraTenantFromIssuer, IDENTITY_UUID_PATTERN } from "./agent_identity";
+
+const PROVIDER_OPTIONS = [
+ { value: "none", label: "No explicit identity binding" },
+ { value: "microsoft_entra", label: "Microsoft Entra ID" },
+];
+const EXECUTION_MODE_OPTIONS = [
+ { value: "autonomous", label: "Autonomous" },
+ { value: "delegated", label: "On behalf of a user" },
+ { value: "both", label: "Both" },
+];
+const EXECUTION_OPTIONS = [
+ { value: "enabled", label: "Enabled" },
+ { value: "disabled", label: "Disabled" },
+];
+
+export const AgentIdentityFields = ({ accessToken }: { accessToken: string | null }) => {
+ const provider = useWatch({ name: "identity_provider" });
+ const mode = useWatch({ name: "execution_mode" });
+ const showScopes = mode !== "autonomous" && mode !== undefined;
+ const [tenants, setTenants] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!accessToken || provider !== "microsoft_entra") return;
+ let active = true;
+ apiClient
+ .get("/v1/agents/identity/providers", { accessToken })
+ .then((issuers) => {
+ if (active) {
+ setError(null);
+ setTenants(
+ issuers.flatMap((issuer) => {
+ const tenant = entraTenantFromIssuer(issuer);
+ return tenant ? [tenant] : [];
+ }),
+ );
+ }
+ })
+ .catch(() => {
+ if (active) setError("Could not load the gateway's trusted identity providers");
+ });
+ return () => {
+ active = false;
+ };
+ }, [accessToken, provider]);
+
+ return (
+ <>
+
+
+
Agent Identity
+
+ Connect an existing identity provider application to this agent. Its name and runtime address can change
+ independently.
+
+
+
+ {({ value, onChange, id }) => (
+
+
+
+
+
+ {PROVIDER_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+ {provider === "microsoft_entra" && (
+ <>
+
+ {({ value, onChange, id }) => (
+
+
+
+
+
+ {tenants.map((tenant) => (
+
+ {tenant}
+
+ ))}
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+ {!error && tenants.length === 0 && (
+
+ No trusted Entra tenant is available. Configure JWT issuer and audience validation on the gateway first.
+ Dashboard Microsoft SSO is configured separately.
+
+ )}
+
+ Find this under{" "}
+
+ Entra App registrations
+
+ , select your agent application, then Overview. No client secret is required here.
+ >
+ }
+ >
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+ {({ value, onChange, id }) => (
+
+
+
+
+
+ {EXECUTION_MODE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+
+ Open{" "}
+
+ Entra Enterprise applications
+
+ , select this application, and copy its Object ID. The App registrations Object ID is a different
+ value.
+ >
+ }
+ >
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+ {showScopes && (
+ <>
+
+ {({ value, onChange, ref, ...control }) => (
+
+ )}
+
+
+ Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must
+ satisfy both user and agent permissions.
+
+ >
+ )}
+
+ {({ value, onChange, id }) => (
+ onChange(next === "enabled")}
+ >
+
+
+
+
+ {EXECUTION_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+
+
+ LiteLLM verifies the agent's Entra token before matching this identity. Saving these fields
+ configures the binding; an authenticated request provides verification. Runtime authentication headers are
+ configured separately.
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx
index f53b03b6a08..b392e270d33 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsPanel.tsx
@@ -145,10 +145,10 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams
- Why do agents need keys?
+ How do agents authenticate?
- Keys scope access to an agent and allow it to call MCP tools. Assign a key when creating an agent or from
- the Virtual Keys page.
+ Agents can authenticate with a virtual key or a trusted identity provider using JWT. Configure an identity
+ binding when adding or editing an agent. JWT authentication does not require a virtual key.
{isAdmin && (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx
index 17bb8bbfec4..68cb4d8c83c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx
@@ -68,6 +68,12 @@ describe("AgentsTable", () => {
expect(within(keylessRow).getByText("Needs Setup")).toBeInTheDocument();
});
+ it("shows JWT configured for agents without a virtual key", () => {
+ render( );
+ expect(screen.getByText("JWT configured")).toBeInTheDocument();
+ expect(screen.queryByText("Needs Setup")).not.toBeInTheDocument();
+ });
+
it("deletes an agent through the ⋯ actions menu", async () => {
const user = userEvent.setup();
const onDeleteClick = vi.fn();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx
index 9ec1eb097d2..002219f5478 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTableColumns.tsx
@@ -136,6 +136,7 @@ export const getAgentsTableColumns = ({
enableSorting: false,
cell: ({ row }) => {
const hasKeys = (row.original.keys?.length ?? 0) > 0;
+ if (row.original.jwt_auth_configured) return ;
return hasKeys ? (
) : (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx
index 457ee656415..67ac770a65f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.integration.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AddAgentForm from "./add_agent_form";
@@ -8,6 +8,7 @@ import type { AgentCreateInfo } from "@/components/networking";
import { chooseSelectOption, renderWithProviders as render } from "../../../../../tests/test-utils";
vi.mock("@/components/networking", () => ({
+ apiClient: { get: vi.fn() },
createAgentCall: vi.fn(),
getAgentCreateMetadata: vi.fn(),
getAgentsList: vi.fn(),
@@ -95,6 +96,73 @@ describe("AddAgentForm submit payload", () => {
.mockResolvedValue({} as never);
});
+ it("clears the provider error when reselecting Entra successfully loads trusted tenants", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ const tenant = "11111111-1111-4111-8111-111111111111";
+ vi.mocked(networking.apiClient.get)
+ .mockReset()
+ .mockRejectedValueOnce(new Error("temporarily unavailable"))
+ .mockResolvedValue([`https://login.microsoftonline.com/${tenant}/v2.0`]);
+ renderForm();
+ await user.click(await screen.findByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Could not load the gateway's trusted identity providers",
+ );
+ await user.click(screen.getByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "No explicit identity binding" }));
+ await user.click(screen.getByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
+ await user.click(screen.getByLabelText("Trusted Entra Tenant"));
+ expect(await screen.findByRole("option", { name: tenant })).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("registers a readable agent with an explicit Entra identity and no virtual key", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
+ const tenant = "11111111-1111-4111-8111-111111111111";
+ const clientId = "22222222-2222-4222-8222-222222222222";
+ vi.mocked(networking.apiClient.get).mockResolvedValue([`https://login.microsoftonline.com/${tenant}/v2.0`]);
+ renderForm();
+ fireEvent.change(await screen.findByLabelText("Agent Name"), { target: { value: "Readable agent" } });
+ fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://runtime.example/a2a" } });
+ fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Readable agent" } });
+ fireEvent.change(screen.getByPlaceholderText("Describe what this agent does..."), {
+ target: { value: "Test agent" },
+ });
+ await user.click(screen.getByLabelText("Identity Provider"));
+ await user.click(await screen.findByRole("option", { name: "Microsoft Entra ID" }));
+ await user.click(screen.getByLabelText("Trusted Entra Tenant"));
+ await user.click(await screen.findByRole("option", { name: tenant }));
+ fireEvent.change(screen.getByLabelText("Application (Client) ID"), { target: { value: clientId } });
+ fireEvent.change(screen.getByLabelText("Enterprise Application Object ID"), {
+ target: { value: "33333333-3333-4333-8333-333333333333" },
+ });
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: /^Next/ }));
+ await user.click(screen.getByRole("button", { name: "Use Entra JWT authentication" }));
+ await user.click(screen.getByRole("button", { name: /Create Agent/ }));
+ await waitFor(() => expect(networking.createAgentCall).toHaveBeenCalledTimes(1));
+ expect(createdPayload().agent_name).toBe("Readable agent");
+ const expectedIdentity = {
+ provider: "microsoft_entra",
+ tenant_id: tenant,
+ client_id: clientId,
+ service_principal_id: "33333333-3333-4333-8333-333333333333",
+ required_roles: [],
+ required_scopes: ["user_impersonation"],
+ };
+ expect(createdPayload().identity).toEqual(expectedIdentity);
+ expect(createdPayload()).not.toHaveProperty("litellm_params.identity");
+ expect(networking.keyCreateForAgentCall).not.toHaveBeenCalled();
+ expect(
+ screen.getByText(
+ "Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection.",
+ ),
+ ).toBeInTheDocument();
+ });
+
it("sends every a2a field the user filled across all collapsible panels", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderForm();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx
index fbf5cf8c1fb..5e8ba145396 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx
@@ -83,7 +83,9 @@ describe("AddAgentForm logos", () => {
expect(titleLogo).toBeInstanceOf(HTMLImageElement);
expect(titleLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
- const selectionLogo = within(await screen.findByRole("combobox")).getByAltText("A2A Agent logo");
+ const selectionLogo = within(await screen.findByRole("combobox", { name: "Agent Type" })).getByAltText(
+ "A2A Agent logo",
+ );
expect(selectionLogo).toBeInstanceOf(HTMLImageElement);
expect(selectionLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png"));
});
@@ -93,14 +95,14 @@ describe("AddAgentForm logos", () => {
await screen.findByAltText("A2A Agent logo");
- expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox"));
+ expect(screen.getByLabelText("Agent Type")).toBe(screen.getByRole("combobox", { name: "Agent Type" }));
});
it("renders the option logo when the agent type dropdown is opened", async () => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderForm();
- const trigger = await screen.findByRole("combobox");
+ const trigger = await screen.findByRole("combobox", { name: "Agent Type" });
await within(trigger).findByAltText("A2A Agent logo");
await user.click(trigger);
@@ -123,7 +125,7 @@ describe("AddAgentForm logos", () => {
expect(screen.queryByAltText("Agent logo")).not.toBeInTheDocument();
expect(within(header).getByText("A")).toBeInTheDocument();
- const trigger = screen.getByRole("combobox");
+ const trigger = screen.getByRole("combobox", { name: "Agent Type" });
fireEvent.error(within(trigger).getByAltText("A2A Agent logo"));
expect(within(trigger).queryByAltText("A2A Agent logo")).not.toBeInTheDocument();
expect(warnSpy).toHaveBeenCalledTimes(2);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx
index 5bd6ea9b83a..b243d9d1601 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx
@@ -1,3 +1,5 @@
+import { AgentIdentityFields } from "./AgentIdentityFields";
+import { withAgentIdentity } from "./agent_identity";
import React, { useState, useEffect } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { toast } from "@/lib/toast";
@@ -287,6 +289,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
const buildAgentData = (values: AgentFormValues): AgentRequestPayload | null => {
if (agentType === CUSTOM_AGENT_TYPE) {
+ if (values.identity_provider === "microsoft_entra") return { agent_name: values.agent_name };
return {
agent_name: values.agent_name,
agent_card_params: {
@@ -353,12 +356,13 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
return;
}
const values = form.getValues();
- const agentData = buildAgentData(values);
- if (!agentData) {
+ const built = buildAgentData(values);
+ if (!built) {
toast.error("Failed to build agent data");
setIsSubmitting(false);
return;
}
+ const agentData = withAgentIdentity(built, values);
// Build object_permission from MCP Tools step (allowed_mcp_servers_and_groups, mcp_tool_permissions)
const mcpServersAndGroups = values.allowed_mcp_servers_and_groups ?? {};
@@ -792,7 +796,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
- For agents that don't follow a standard protocol, just needs a virtual key
+ For outbound agents using an identity provider or virtual key
@@ -801,6 +805,8 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
+
+
{agentType === CUSTOM_AGENT_TYPE ? (
@@ -910,7 +916,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
name="team_id"
label={labelWithHint(
"Assign to Team",
- "Optionally assign this agent to a team. The agent and its key will belong to the selected team.",
+ "Optionally select a team for the virtual key. The agent identity and its permissions are managed separately.",
)}
>
{({ value, onChange }) => (
@@ -920,6 +926,11 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
+ {form.getValues("identity_provider") === "microsoft_entra" && (
+
+ This agent will authenticate with Microsoft Entra ID. You can skip virtual key creation.
+
+ )}
setKeyAssignOption(value as "create_new" | "existing_key" | "skip")}
@@ -1004,7 +1015,9 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
className="text-sm text-muted-foreground underline hover:text-foreground"
onClick={() => setKeyAssignOption("skip")}
>
- Skip for now — I'll assign a key later
+ {form.getValues("identity_provider") === "microsoft_entra"
+ ? "Use Entra JWT authentication"
+ : "Skip for now, I’ll assign a key later"}
@@ -1033,7 +1046,9 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok
)}
{!createdKeyValue && !assignedKeyAlias && keyAssignOption === "skip" && (
- No key assigned. You can create one from the Virtual Keys page.
+ {form.getValues("identity_provider") === "microsoft_entra"
+ ? "Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection."
+ : "No key assigned. You can create one from the Virtual Keys page."}
)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts
index 16ce6848402..6ec3c3181f7 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_config.ts
@@ -1,3 +1,4 @@
+import { parseIdentityForForm } from "./agent_identity";
/**
* Shared configuration for agent form fields
* Used across create, view, and update operations
@@ -57,7 +58,7 @@ export const AGENT_FORM_CONFIG: {
name: "description",
label: "Description",
type: "textarea",
- required: true,
+ required: false,
placeholder: "Describe what this agent does...",
rows: 3,
},
@@ -340,6 +341,7 @@ export const parseAccessGroupIdsForForm = (agent: { access_group_ids?: string[]
});
export const parseMcpPermissionsForForm = (agent: any) => ({
+ ...parseIdentityForForm(agent),
allowed_mcp_servers_and_groups: {
servers: agent.object_permission?.mcp_servers ?? [],
accessGroups: agent.object_permission?.mcp_access_groups ?? [],
@@ -363,8 +365,9 @@ export const buildMcpObjectPermission = (values: any) => ({
* Parse agent data for form fields
*/
export const parseAgentForForm = (agent: any) => {
+ const card = agent.agent_card_params ?? {};
const skills =
- agent.agent_card_params?.skills?.map((skill: any) => ({
+ card.skills?.map((skill: any) => ({
...skill,
tags: skill.tags,
examples: skill.examples || [],
@@ -372,18 +375,18 @@ export const parseAgentForForm = (agent: any) => {
return {
agent_name: agent.agent_name,
- name: agent.agent_card_params?.name,
- description: agent.agent_card_params?.description,
- url: agent.agent_card_params?.url,
- version: agent.agent_card_params?.version,
- protocolVersion: agent.agent_card_params?.protocolVersion,
- streaming: agent.agent_card_params?.capabilities?.streaming,
- pushNotifications: agent.agent_card_params?.capabilities?.pushNotifications,
- stateTransitionHistory: agent.agent_card_params?.capabilities?.stateTransitionHistory,
+ name: card.name || agent.agent_name,
+ description: card.description,
+ url: card.url,
+ version: card.version,
+ protocolVersion: card.protocolVersion,
+ streaming: card.capabilities?.streaming,
+ pushNotifications: card.capabilities?.pushNotifications,
+ stateTransitionHistory: card.capabilities?.stateTransitionHistory,
skills: skills,
- iconUrl: agent.agent_card_params?.iconUrl,
- documentationUrl: agent.agent_card_params?.documentationUrl,
- supportsAuthenticatedExtendedCard: agent.agent_card_params?.supportsAuthenticatedExtendedCard,
+ iconUrl: card.iconUrl,
+ documentationUrl: card.documentationUrl,
+ supportsAuthenticatedExtendedCard: card.supportsAuthenticatedExtendedCard,
model: agent.litellm_params?.model,
make_public: agent.litellm_params?.make_public,
cost_per_query: agent.litellm_params?.cost_per_query,
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts
new file mode 100644
index 00000000000..0639e7a6dd4
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildIdentityParams,
+ entraTenantFromIssuer,
+ parseIdentityForForm,
+ readAgentIdentity,
+ withAgentIdentity,
+} from "./agent_identity";
+
+const identity = {
+ provider: "microsoft_entra",
+ tenant_id: "11111111-1111-4111-8111-111111111111",
+ client_id: "22222222-2222-4222-8222-222222222222",
+ service_principal_id: "33333333-3333-4333-8333-333333333333",
+ required_roles: ["Agent.Invoke"],
+ required_scopes: ["user_impersonation"],
+} satisfies import("./agent_identity").EntraAgentIdentity;
+
+describe("agent identity configuration", () => {
+ it("round trips an existing binding independently of the agent name and runtime", () => {
+ const values = {
+ ...parseIdentityForForm({
+ identity: { ...identity, agent_id: "stable", active: true, revision: "rev", issuer: "https://issuer.example" },
+ }),
+ agent_name: "Renamed",
+ url: "https://new-runtime.example",
+ };
+ expect(buildIdentityParams(values)).toEqual({ identity });
+ });
+ it("preserves untouched bindings and explicitly clears a removed binding", () => {
+ expect(buildIdentityParams({ agent_name: "legacy" })).toEqual({});
+ expect(buildIdentityParams({ identity_provider: "none" }, identity)).toEqual({ identity: null });
+ expect(parseIdentityForForm({}).identity_provider).toBe("none");
+ });
+ it.each([
+ null,
+ {},
+ "invalid",
+ { ...identity, client_id: "bad" },
+ { ...identity, tenant_id: 3 },
+ { ...identity, provider: "other" },
+ ])("rejects malformed bindings: %j", (value) => {
+ expect(readAgentIdentity(value)).toBeNull();
+ });
+ it("rejects incomplete submissions", () => {
+ expect(() => buildIdentityParams({ identity_provider: "microsoft_entra" })).toThrow("Enter valid Entra");
+ });
+ it("submits identity as top-level settings without changing runtime parameters", () => {
+ const formValues = {
+ identity_provider: "microsoft_entra",
+ identity_tenant_id: identity.tenant_id,
+ identity_client_id: identity.client_id,
+ identity_service_principal_id: identity.service_principal_id,
+ execution_mode: "both",
+ enabled: false,
+ };
+ const payload = withAgentIdentity({ litellm_params: { model: "runtime" } }, formValues);
+ expect(payload.litellm_params).toEqual({ model: "runtime" });
+ expect(payload.identity).toMatchObject({
+ client_id: identity.client_id,
+ service_principal_id: identity.service_principal_id,
+ });
+ expect(payload.execution_mode).toBe("both");
+ expect(payload.enabled).toBe(false);
+ });
+ it("requires a service principal for autonomous execution", () => {
+ const values = {
+ identity_provider: "microsoft_entra",
+ identity_tenant_id: identity.tenant_id,
+ identity_client_id: identity.client_id,
+ execution_mode: "autonomous",
+ };
+ expect(() => buildIdentityParams(values)).toThrow("Enterprise application Object ID");
+ });
+
+ it("only offers tenant-specific Microsoft issuers", () => {
+ expect(entraTenantFromIssuer(`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`)).toBe(
+ identity.tenant_id,
+ );
+ expect(entraTenantFromIssuer("https://attacker.example/tenant/v2.0")).toBeNull();
+ expect(entraTenantFromIssuer("https://login.microsoftonline.com/common/v2.0")).toBeNull();
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts
new file mode 100644
index 00000000000..23045adcf20
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts
@@ -0,0 +1,108 @@
+import { z } from "zod";
+import type { components } from "@/lib/http/schema";
+import type { AgentFormValues, AgentRequestPayload } from "./AgentFormKit";
+
+export type EntraAgentIdentity = components["schemas"]["EntraIdentityConfig"];
+type AgentIdentityState = Pick<
+ components["schemas"]["AgentResponse"],
+ "identity" | "enabled" | "execution_mode" | "agent_card_params"
+>;
+
+export const IDENTITY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const stringGrants = (fallback: string[]) =>
+ z
+ .unknown()
+ .transform((value) =>
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : fallback,
+ );
+
+const identityShape = {
+ provider: z.literal("microsoft_entra"),
+ tenant_id: z.string().regex(IDENTITY_UUID_PATTERN),
+ client_id: z.string().regex(IDENTITY_UUID_PATTERN),
+ service_principal_id: z.string().regex(IDENTITY_UUID_PATTERN).nullable().default(null),
+ required_roles: stringGrants([]),
+ required_scopes: stringGrants(["user_impersonation"]),
+};
+const identitySchema = z.object(identityShape);
+
+export const readAgentIdentity = (value: unknown): EntraAgentIdentity | null => {
+ const parsed = identitySchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+};
+
+const identityFormFields = (identity: EntraAgentIdentity | null): AgentFormValues => ({
+ identity_provider: identity?.provider ?? "none",
+ identity_tenant_id: identity?.tenant_id ?? "",
+ identity_client_id: identity?.client_id ?? "",
+ identity_service_principal_id: identity?.service_principal_id ?? "",
+ identity_required_roles: identity?.required_roles?.join(", ") ?? "",
+ identity_required_scopes: identity?.required_scopes?.join(", ") ?? "user_impersonation",
+});
+
+export const parseIdentityForForm = (agent?: Partial | null): AgentFormValues => {
+ const identity = agent?.identity?.active === false ? null : readAgentIdentity(agent?.identity);
+ return {
+ ...identityFormFields(identity),
+ execution_mode: agent?.execution_mode ?? "autonomous",
+ enabled: agent?.enabled ?? true,
+ };
+};
+
+const splitGrants = (value: unknown, fallback: string[]): string[] =>
+ typeof value === "string"
+ ? value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean)
+ : fallback;
+
+export const buildIdentityParams = (
+ values: AgentFormValues,
+ existingIdentity?: unknown,
+): { identity?: EntraAgentIdentity | null } => {
+ if (values.identity_provider === undefined) return {};
+ if (values.identity_provider !== "microsoft_entra")
+ return readAgentIdentity(existingIdentity) ? { identity: null } : {};
+ const candidate: EntraAgentIdentity = {
+ provider: "microsoft_entra",
+ tenant_id: typeof values.identity_tenant_id === "string" ? values.identity_tenant_id.trim().toLowerCase() : "",
+ client_id: typeof values.identity_client_id === "string" ? values.identity_client_id.trim().toLowerCase() : "",
+ service_principal_id:
+ typeof values.identity_service_principal_id === "string" && values.identity_service_principal_id.trim()
+ ? values.identity_service_principal_id.trim().toLowerCase()
+ : null,
+ required_roles: splitGrants(values.identity_required_roles, []),
+ required_scopes: splitGrants(values.identity_required_scopes, ["user_impersonation"]),
+ };
+ const identity = readAgentIdentity(candidate);
+ if (!identity) throw new Error("Enter valid Entra tenant, application client and service principal IDs");
+ if (values.execution_mode !== "delegated" && !identity.service_principal_id)
+ throw new Error("Autonomous agents require the Enterprise application Object ID");
+ return { identity };
+};
+
+export const entraTenantFromIssuer = (issuer: string): string | null => {
+ const match = /^https:\/\/login\.microsoftonline\.com\/([^/]+)\/v2\.0$/.exec(issuer);
+ return match && IDENTITY_UUID_PATTERN.test(match[1]) ? match[1] : null;
+};
+
+export const withAgentIdentity = (
+ payload: AgentRequestPayload,
+ values: AgentFormValues,
+ existing?: Partial,
+ cardEdited = false,
+): AgentRequestPayload => {
+ const { agent_card_params, ...settings } = payload;
+ const hasCard = !existing || cardEdited || Object.keys(existing.agent_card_params ?? {}).length > 0;
+ const identityFields = buildIdentityParams(values, existing?.identity);
+ const managed = values.identity_provider === "microsoft_entra" || Boolean(readAgentIdentity(existing?.identity));
+ return {
+ ...settings,
+ ...(hasCard && agent_card_params ? { agent_card_params } : {}),
+ ...identityFields,
+ ...(managed && values.execution_mode !== undefined ? { execution_mode: values.execution_mode } : {}),
+ ...(managed && values.enabled !== undefined ? { enabled: values.enabled } : {}),
+ };
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
index 37e00766a75..e08cab776c4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.integration.test.tsx
@@ -8,6 +8,7 @@ import * as networking from "@/components/networking";
import type { AgentCreateInfo } from "@/components/networking";
vi.mock("@/components/networking", () => ({
+ apiClient: { get: vi.fn() },
getAgentInfo: vi.fn(),
patchAgentCall: vi.fn(),
getAgentCreateMetadata: vi.fn(),
@@ -155,6 +156,65 @@ describe("AgentInfoView update payload", () => {
.mockResolvedValue({} as never);
});
+ it.each([
+ { card: "complete", editCard: false },
+ { card: "empty", editCard: false },
+ { card: "empty", editCard: true },
+ ])("preserves identity and runtime intent with a $card card (card edits: $editCard)", async ({ card, editCard }) => {
+ const user = setup();
+ const identity = {
+ provider: "microsoft_entra",
+ tenant_id: "11111111-1111-4111-8111-111111111111",
+ client_id: "22222222-2222-4222-8222-222222222222",
+ service_principal_id: "33333333-3333-4333-8333-333333333333",
+ };
+ const params = { ...A2A_AGENT.litellm_params, require_trace_id_on_calls_by_agent: true };
+ vi.mocked(networking.getAgentInfo).mockResolvedValue({
+ ...A2A_AGENT,
+ agent_card_params: card === "empty" ? {} : A2A_AGENT.agent_card_params,
+ litellm_params: params,
+ identity: { ...identity, agent_id: "agent-1", issuer: "https://issuer.example", revision: "rev", active: true },
+ identity_managed: true,
+ execution_mode: "autonomous",
+ enabled: true,
+ access_group_ids: ["ag-entra"],
+ } as never);
+ vi.mocked(networking.apiClient.get).mockImplementation(async (path) =>
+ path.endsWith("/providers")
+ ? [`https://login.microsoftonline.com/${identity.tenant_id}/v2.0`]
+ : { last_authenticated_at: null },
+ );
+ renderView();
+ expect(await screen.findByText("Configured, awaiting an authenticated request")).toBeInTheDocument();
+ await openEditor(user);
+ expect(screen.getByLabelText("Application (Client) ID")).toHaveValue(identity.client_id);
+ expect(screen.getByRole("combobox", { name: "Identity Provider" })).toHaveTextContent("Microsoft Entra ID");
+ expect(screen.getByRole("combobox", { name: "Execution Mode" })).toHaveTextContent("Autonomous");
+ expect(screen.getByRole("combobox", { name: /^Execution$/ })).toHaveTextContent("Enabled");
+ fireEvent.change(screen.getByLabelText("Agent Name"), { target: { value: "Renamed agent" } });
+ if (editCard) {
+ fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Configured runtime" } });
+ fireEvent.change(screen.getByLabelText("URL"), { target: { value: "https://runtime.example/a2a" } });
+ }
+ await save(user);
+ expect(patchedPayload().agent_name).toBe("Renamed agent");
+ expect(patchedPayload()).not.toHaveProperty("litellm_params");
+ expect(patchedPayload().agent_card_params === undefined).toBe(card === "empty" && !editCard);
+ if (editCard) {
+ expect(patchedPayload().agent_card_params).toMatchObject({
+ name: "Configured runtime",
+ url: "https://runtime.example/a2a",
+ });
+ }
+ expect(patchedPayload().identity).toMatchObject(identity);
+ expect(patchedPayload().access_group_ids).toEqual(["ag-entra"]);
+ expect(networking.patchAgentCall).toHaveBeenCalledWith(
+ "tok",
+ "agent-1",
+ expect.objectContaining({ agent_name: "Renamed agent" }),
+ );
+ });
+
it("sends only the fields whose panel has been opened, dropping the rest", async () => {
const user = setup();
renderView();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
index 19b1ee8ca48..4eb3534ebe3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.test.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
+import AgentFormFields from "./agent_form_fields";
import * as networking from "@/components/networking";
import type { Agent } from "@/components/agents/types";
@@ -16,12 +17,16 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
}));
+vi.mock("./AgentIdentityDetails", () => ({
+ AgentIdentityDetails: () => null,
+}));
+
vi.mock("./agent_card_discovery", () => ({
default: () =>
,
}));
vi.mock("./agent_form_fields", () => ({
- default: () =>
,
+ default: vi.fn(() =>
),
unmountedA2AFieldNames: () => [],
}));
@@ -77,6 +82,9 @@ const agent = {
describe("AgentInfoView settings", () => {
beforeEach(() => {
vi.restoreAllMocks();
+ vi.mocked(AgentFormFields)
+ .mockReset()
+ .mockImplementation(() =>
);
vi.mocked(networking.getAgentInfo).mockReset().mockResolvedValue(agent);
vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([]);
vi.mocked(networking.patchAgentCall).mockReset().mockResolvedValue({});
@@ -104,6 +112,23 @@ describe("AgentInfoView settings", () => {
expect(payload.access_group_ids).toEqual([]);
});
+ it("saves unrelated settings when the existing card has no description", async () => {
+ const actual = await vi.importActual("./agent_form_fields");
+ vi.mocked(AgentFormFields).mockImplementation(actual.default);
+ const { description: _description, ...card } = agent.agent_card_params ?? {};
+ vi.mocked(networking.getAgentInfo).mockResolvedValue({ ...agent, agent_card_params: card });
+ render( );
+ fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
+ fireEvent.click(screen.getByRole("button", { name: "Edit Settings" }));
+ expect(await screen.findByLabelText("Description")).toHaveValue("");
+ fireEvent.change(screen.getByLabelText("TPM Limit"), { target: { value: "42" } });
+ fireEvent.click(screen.getByRole("button", { name: /Save Changes/ }));
+ await waitFor(() => expect(networking.patchAgentCall).toHaveBeenCalledOnce());
+ const [, , payload] = vi.mocked(networking.patchAgentCall).mock.calls[0];
+ expect(payload.tpm_limit).toBe(42);
+ expect(payload.agent_card_params?.description).toBe("");
+ });
+
it("sends the newly attached access group in the update payload", async () => {
render( );
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
index 6e7389a3fa1..c9f154c3ce1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_info.tsx
@@ -1,3 +1,6 @@
+import { AgentIdentityFields } from "./AgentIdentityFields";
+import { AgentIdentityDetails } from "./AgentIdentityDetails";
+import { withAgentIdentity } from "./agent_identity";
import React, { useState, useEffect, useMemo } from "react";
import { cx } from "@/lib/cva.config";
import { FormProvider, useForm, useWatch } from "react-hook-form";
@@ -235,9 +238,14 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
const updateData = appliedDiscoveredSelection
? overlayDiscoveredCardParams(built, appliedDiscoveredSelection.selected_card)
: built;
+ const cardEdited =
+ Boolean(appliedDiscoveredSelection) ||
+ [AGENT_FORM_CONFIG.basic, AGENT_FORM_CONFIG.skills, AGENT_FORM_CONFIG.capabilities, AGENT_FORM_CONFIG.optional]
+ .flatMap((section) => section.fields)
+ .some((field) => form.getFieldState(field.name).isDirty);
await patchAgentCall(accessToken, agentId, {
- ...updateData,
+ ...withAgentIdentity(updateData, values, agent, cardEdited),
object_permission: buildMcpObjectPermission(values),
access_group_ids: values.access_group_ids ?? [],
});
@@ -337,6 +345,12 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
{/* Overview Panel */}
+
{agent.agent_id}
{agent.agent_name}
@@ -505,6 +519,8 @@ const AgentInfoView: React.FC = ({ agentId, onClose, accessT
)}
+
+
{discoveryRequest && (
-
Agents
+
Allowed agents to call
{totalCount}
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx
index 68590b6de2d..c6c2714bc49 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx
@@ -433,3 +433,17 @@ describe("TTFT column", () => {
expect(screen.getByText("1.00")).toBeInTheDocument();
});
});
+
+describe("Request outcome", () => {
+ it("shows a failed agent outcome even when metadata has no status", () => {
+ renderRows([logEntry({ call_type: "asend_message", status: "failure", session_total_count: 4 })]);
+ expect(screen.getByText("Failure")).toBeInTheDocument();
+ expect(screen.queryByText("Success")).not.toBeInTheDocument();
+ });
+
+ it("prefers the recorded outcome over stale metadata", () => {
+ renderRows([logEntry({ status: "success", metadata: { status: "failure" } })]);
+ expect(screen.getByText("Success")).toBeInTheDocument();
+ expect(screen.queryByText("Failure")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
index df7f55d7d76..80e7b512471 100644
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx
@@ -143,7 +143,7 @@ export const getRequestLogsTableColumns = ({
enableSorting: false,
meta: { skeleton: "badge" },
cell: ({ row }) => {
- const status = readMetaString(row.original.metadata, "status") ?? "Success";
+ const status = row.original.status || readMetaString(row.original.metadata, "status") || "Success";
const isSuccess = status.toLowerCase() !== "failure";
const batchCounts = isSuccess ? getBatchRequestCounts(row.original.metadata) : undefined;
if (batchCounts && batchCounts.failed > 0) {
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index e9ecd3492f7..d79018375a5 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -18157,6 +18157,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/agents/identity/providers": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Agent Identity Providers */
+ get: operations["get_agent_identity_providers_v1_agents_identity_providers_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/agents/make_public": {
parameters: {
query?: never;
@@ -18306,6 +18323,23 @@ export interface paths {
patch: operations["patch_agent_v1_agents__agent_id__patch"];
trace?: never;
};
+ "/v1/agents/{agent_id}/identity": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get Agent Identity Status */
+ get: operations["get_agent_identity_status_v1_agents__agent_id__identity_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/agents/{agent_id}/kill_switch": {
parameters: {
query?: never;
@@ -24368,11 +24402,19 @@ export interface components {
AgentConfig: {
/** Access Group Ids */
access_group_ids?: string[] | null;
- agent_card_params: components["schemas"]["AgentCard"];
+ agent_card_params?: components["schemas"]["AgentCard"];
/** Agent Name */
agent_name: string;
+ /** Enabled */
+ enabled?: boolean;
+ /**
+ * Execution Mode
+ * @enum {string}
+ */
+ execution_mode?: "autonomous" | "delegated" | "both";
/** Extra Headers */
extra_headers?: string[] | null;
+ identity?: components["schemas"]["EntraIdentityConfig"] | null;
kill_switch?: components["schemas"]["AgentKillSwitchConfig"] | null;
/** Litellm Params */
litellm_params?: {
@@ -30360,6 +30402,33 @@ export interface components {
/** Template Id */
template_id: string;
};
+ /** EntraIdentityConfig */
+ EntraIdentityConfig: {
+ /** Client Id */
+ client_id: string;
+ /**
+ * Provider
+ * @constant
+ */
+ provider: "microsoft_entra";
+ /**
+ * Required Roles
+ * @default []
+ */
+ required_roles: string[];
+ /**
+ * Required Scopes
+ * @description Required delegated scopes. An empty list accepts any nonempty scope granted for this gateway.
+ * @default [
+ * "user_impersonation"
+ * ]
+ */
+ required_scopes: string[];
+ /** Service Principal Id */
+ service_principal_id?: string | null;
+ /** Tenant Id */
+ tenant_id: string;
+ };
/** EnvironmentReport */
EnvironmentReport: {
/** Config Lines */
@@ -35776,6 +35845,28 @@ export interface components {
/** Mcp Server Ids */
mcp_server_ids: string[];
};
+ /** ManagedAgentIdentityStatus */
+ ManagedAgentIdentityStatus: {
+ /**
+ * Enabled
+ * @default true
+ */
+ enabled: boolean;
+ /**
+ * Execution Mode
+ * @default autonomous
+ * @enum {string}
+ */
+ execution_mode: "autonomous" | "delegated" | "both";
+ identity?: components["schemas"]["AgentIdentityBinding"] | null;
+ /**
+ * Identity Managed
+ * @default false
+ */
+ identity_managed: boolean;
+ /** Last Authenticated At */
+ last_authenticated_at?: string | null;
+ };
/**
* Mcp
* @description Give the model access to additional tools via remote Model Context Protocol
@@ -37931,8 +38022,16 @@ export interface components {
agent_card_params?: components["schemas"]["AgentCard"];
/** Agent Name */
agent_name?: string;
+ /** Enabled */
+ enabled?: boolean;
+ /**
+ * Execution Mode
+ * @enum {string}
+ */
+ execution_mode?: "autonomous" | "delegated" | "both";
/** Extra Headers */
extra_headers?: string[] | null;
+ identity?: components["schemas"]["EntraIdentityConfig"] | null;
kill_switch?: components["schemas"]["AgentKillSwitchConfig"] | null;
/** Litellm Params */
litellm_params?: {
@@ -70837,6 +70936,26 @@ export interface operations {
};
};
};
+ get_agent_identity_providers_v1_agents_identity_providers_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": string[];
+ };
+ };
+ };
+ };
make_agents_public_v1_agents_make_public_post: {
parameters: {
query?: never;
@@ -71002,6 +71121,37 @@ export interface operations {
};
};
};
+ get_agent_identity_status_v1_agents__agent_id__identity_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ agent_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ManagedAgentIdentityStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
trigger_agent_kill_switch_v1_agents__agent_id__kill_switch_post: {
parameters: {
query?: never;