From 52b9fa2ba177d453941c25e386556c883b35ea9c Mon Sep 17 00:00:00 2001 From: joshua-berri Date: Wed, 30 Sep 2026 15:21:21 -0700 Subject: [PATCH] feat(agents): add identity registration and dashboard controls (#43723) * feat(agents): identity registration and dashboard * fix(agents): preserve retired identity ownership * fix(agents): preserve configuration during identity updates * fix(agents): retain intentional card edits in the dashboard * fix: remove mutable agent identity registration constructions --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 229 +++++++- .../proxy/agent_endpoints/agent_registry.py | 212 ++++--- litellm/proxy/agent_endpoints/endpoints.py | 109 +++- .../proxy/agent_endpoints/managed_identity.py | 30 +- litellm/types/agents.py | 9 +- .../agent_endpoints/test_agent_registry.py | 515 +++++++++++++++--- .../proxy/agent_endpoints/test_endpoints.py | 320 ++++++++++- .../agent_endpoints/test_managed_identity.py | 4 +- .../_components/AgentIdentityDetails.test.tsx | 43 ++ .../_components/AgentIdentityDetails.tsx | 81 +++ .../_components/AgentIdentityFields.tsx | 261 +++++++++ .../agents/_components/AgentsPanel.tsx | 6 +- .../agents/_components/AgentsTable.test.tsx | 6 + .../agents/_components/AgentsTableColumns.tsx | 1 + .../add_agent_form.integration.test.tsx | 70 ++- .../_components/add_agent_form.test.tsx | 10 +- .../agents/_components/add_agent_form.tsx | 27 +- .../agents/_components/agent_config.ts | 29 +- .../agents/_components/agent_identity.test.ts | 83 +++ .../agents/_components/agent_identity.ts | 108 ++++ .../agent_info.integration.test.tsx | 60 ++ .../agents/_components/agent_info.test.tsx | 27 +- .../agents/_components/agent_info.tsx | 18 +- .../src/components/agents/types.ts | 5 + .../permissions/AgentPermissions.tsx | 2 +- .../RequestLogsTableColumns.test.tsx | 14 + .../view_logs/RequestLogsTableColumns.tsx | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 152 +++++- 28 files changed, 2197 insertions(+), 236 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityDetails.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentIdentityFields.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/agent_identity.ts 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. +

+
+ + + View request logs + +
+
+ ); +}; 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 === "microsoft_entra" && ( + <> + + {({ value, onChange, id }) => ( + + )} + + {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 }) => ( + + )} + + + 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 }) => ( + + )} + +

+ 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;