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>
This commit is contained in:
joshua-berri 2026-09-30 15:21:21 -07:00 • committed by GitHub
parent f39c811d34
commit 52b9fa2ba1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2197 additions and 236 deletions

View file

@ -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 <your_api_key>\" \\\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 <your_api_key>\"\n```",

View file

@ -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:

View file

@ -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)

View file

@ -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,

View file

@ -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]

View file

@ -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",
}
}

View file

@ -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

View file

@ -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:

View file

@ -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(<AgentIdentityDetails agentId="native" identity={identity} accessToken="admin" isAdmin />);
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(
<AgentIdentityDetails agentId="native" identity={identity} accessToken="user" isAdmin={false} />,
);
expect(screen.queryByRole("region", { name: "Agent Identity" })).not.toBeInTheDocument();
expect(apiClient.get).not.toHaveBeenCalled();
});
});

View file

@ -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<components["schemas"]["ManagedAgentIdentityStatus"]>(
`/v1/agents/${encodeURIComponent(agentId)}/identity`,
{
accessToken: accessToken ?? "",
},
),
enabled: Boolean(isAdmin && accessToken && identity),
});
if (!identity || !isAdmin) return null;
const executionLabel = data?.enabled ? "Enabled" : "Disabled";
return (
<section aria-label="Agent Identity" className="mb-6 space-y-2 rounded-lg border border-border p-4">
<h3 className="font-medium">Agent Identity: Microsoft Entra ID</h3>
<p className="text-sm">
Tenant: <span className="font-mono">{identity.tenant_id}</span>
</p>
<>
<p className="text-sm">
Application (Client) ID: <span className="font-mono">{identity.client_id}</span>
</p>
<p className="text-sm">Enterprise application Object ID: {identity.service_principal_id || "Not configured"}</p>
</>
<p className="text-sm">
Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
</p>
<p className="text-sm">
{data?.identity?.active === false
? "Identity unbound; execution is disabled"
: authenticationMessage(isError, data?.last_authenticated_at)}
</p>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
disabled={isFetching}
onClick={() => {
void refetch();
}}
>
Refresh authentication evidence
</Button>
<a className="text-sm underline" href="/ui/logs/">
View request logs
</a>
</div>
</section>
);
};

View file

@ -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<AgentFormValues>({ name: "identity_provider" });
const mode = useWatch<AgentFormValues>({ name: "execution_mode" });
const showScopes = mode !== "autonomous" && mode !== undefined;
const [tenants, setTenants] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken || provider !== "microsoft_entra") return;
let active = true;
apiClient
.get<string[]>("/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 (
<>
<section aria-label="Agent Identity" className="my-6 space-y-4 rounded-lg border border-border p-4">
<div>
<h3 className="font-medium">Agent Identity</h3>
<p className="mt-1 text-sm text-muted-foreground">
Connect an existing identity provider application to this agent. Its name and runtime address can change
independently.
</p>
</div>
<AgentFormField name="identity_provider" label="Identity Provider" defaultValue="none">
{({ value, onChange, id }) => (
<Select
items={PROVIDER_OPTIONS}
value={typeof value === "string" ? value : "none"}
onValueChange={onChange}
>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</AgentFormField>
{provider === "microsoft_entra" && (
<>
<AgentFormField
name="identity_tenant_id"
label="Trusted Entra Tenant"
rules={{ required: "Select a trusted tenant" }}
>
{({ value, onChange, id }) => (
<Select value={typeof value === "string" ? value : ""} onValueChange={onChange}>
<SelectTrigger id={id}>
<SelectValue placeholder="Select the gateway's trusted tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map((tenant) => (
<SelectItem key={tenant} value={tenant}>
{tenant}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</AgentFormField>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{!error && tenants.length === 0 && (
<p className="text-sm text-muted-foreground">
No trusted Entra tenant is available. Configure JWT issuer and audience validation on the gateway first.
Dashboard Microsoft SSO is configured separately.
</p>
)}
<AgentFormField
name="identity_client_id"
label="Application (Client) ID"
rules={{
required: "Enter the Entra application client ID",
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid application client UUID" },
}}
description={
<>
Find this under{" "}
<a className="underline" href="https://entra.microsoft.com/" target="_blank" rel="noreferrer">
Entra App registrations
</a>
, select your agent application, then Overview. No client secret is required here.
</>
}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
value={typeof value === "string" ? value : ""}
onChange={onChange}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/>
)}
</AgentFormField>
<AgentFormField name="execution_mode" label="Execution Mode" defaultValue="autonomous">
{({ value, onChange, id }) => (
<Select
items={EXECUTION_MODE_OPTIONS}
value={typeof value === "string" ? value : "autonomous"}
onValueChange={onChange}
>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXECUTION_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</AgentFormField>
<AgentFormField
name="identity_service_principal_id"
label="Enterprise Application Object ID"
rules={{
required: mode !== "delegated" ? "Enter the service principal Object ID" : false,
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid service principal UUID" },
}}
description={
<>
Open{" "}
<a
className="underline"
href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview"
target="_blank"
rel="noreferrer"
>
Entra Enterprise applications
</a>
, select this application, and copy its Object ID. The App registrations Object ID is a different
value.
</>
}
>
{({ value, onChange, ref, ...control }) => (
<Input {...control} ref={ref} value={typeof value === "string" ? value : ""} onChange={onChange} />
)}
</AgentFormField>
<AgentFormField
name="identity_required_roles"
label="Required Application Roles"
description="Comma-separated role values required on autonomous application tokens"
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
value={typeof value === "string" ? value : ""}
onChange={onChange}
placeholder="Agent.Invoke"
/>
)}
</AgentFormField>
{showScopes && (
<>
<AgentFormField
name="identity_required_scopes"
label="Required Delegated Scopes"
defaultValue="user_impersonation"
rules={{ required: "Enter a delegated scope" }}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
value={typeof value === "string" ? value : "user_impersonation"}
onChange={onChange}
/>
)}
</AgentFormField>
<p className="text-sm text-muted-foreground">
Users must first sign in through this gateway&apos;s Microsoft SSO. Subsequent delegated calls must
satisfy both user and agent permissions.
</p>
</>
)}
<AgentFormField name="enabled" label="Execution" defaultValue={true}>
{({ value, onChange, id }) => (
<Select
items={EXECUTION_OPTIONS}
value={value === false ? "disabled" : "enabled"}
onValueChange={(next) => onChange(next === "enabled")}
>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXECUTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</AgentFormField>
<p className="text-sm text-muted-foreground">
LiteLLM verifies the agent&apos;s Entra token before matching this identity. Saving these fields
configures the binding; an authenticated request provides verification. Runtime authentication headers are
configured separately.
</p>
</>
)}
</section>
</>
);
};

View file

@ -145,10 +145,10 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
</p>
<Alert className="mb-3">
<Info />
<AlertTitle>Why do agents need keys?</AlertTitle>
<AlertTitle>How do agents authenticate?</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
{isAdmin && (

View file

@ -68,6 +68,12 @@ describe("AgentsTable", () => {
expect(within(keylessRow).getByText("Needs Setup")).toBeInTheDocument();
});
it("shows JWT configured for agents without a virtual key", () => {
render(<AgentsTable agents={[makeAgent({ keys: [], jwt_auth_configured: true })]} {...baseProps} />);
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();

View file

@ -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 <StatusBadge tone="success" label="JWT configured" />;
return hasKeys ? (
<StatusBadge tone="success" label="Active" />
) : (

View file

@ -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();

View file

@ -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);

View file

@ -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<AddAgentFormProps> = ({ 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<AddAgentFormProps> = ({ 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<AddAgentFormProps> = ({ visible, onClose, accessTok
<StatusBadge tone="warning" label="GENERIC" className="h-4 px-1 text-[10px]" />
</span>
<span className="block text-xs whitespace-normal text-warning">
For agents that don&apos;t follow a standard protocol, just needs a virtual key
For outbound agents using an identity provider or virtual key
</span>
</span>
</span>
@ -801,6 +805,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
</Select>
</Field>
<AgentIdentityFields accessToken={accessToken} />
<div className="mt-4">
{agentType === CUSTOM_AGENT_TYPE ? (
<FieldGroup>
@ -910,7 +916,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ 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<AddAgentFormProps> = ({ visible, onClose, accessTok
<Separator className="my-4" />
{form.getValues("identity_provider") === "microsoft_entra" && (
<p className="mb-4 text-sm text-muted-foreground">
This agent will authenticate with Microsoft Entra ID. You can skip virtual key creation.
</p>
)}
<RadioGroup
value={keyAssignOption}
onValueChange={(value) => setKeyAssignOption(value as "create_new" | "existing_key" | "skip")}
@ -1004,7 +1015,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
className="text-sm text-muted-foreground underline hover:text-foreground"
onClick={() => setKeyAssignOption("skip")}
>
Skip for now — I&apos;ll assign a key later
{form.getValues("identity_provider") === "microsoft_entra"
? "Use Entra JWT authentication"
: "Skip for now, I’ll assign a key later"}
</button>
</div>
</div>
@ -1033,7 +1046,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
)}
{!createdKeyValue && !assignedKeyAlias && keyAssignOption === "skip" && (
<p className="mt-2 text-sm text-muted-foreground">
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."}
</p>
)}
</div>

View file

@ -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,

View file

@ -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();
});
});

View file

@ -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<AgentIdentityState> | 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<AgentIdentityState>,
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 } : {}),
};
};

View file

@ -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();

View file

@ -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: () => <div data-testid="agent-card-discovery" />,
}));
vi.mock("./agent_form_fields", () => ({
default: () => <div data-testid="agent-form-fields" />,
default: vi.fn(() => <div data-testid="agent-form-fields" />),
unmountedA2AFieldNames: () => [],
}));
@ -77,6 +82,9 @@ const agent = {
describe("AgentInfoView settings", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(AgentFormFields)
.mockReset()
.mockImplementation(() => <div data-testid="agent-form-fields" />);
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<typeof import("./agent_form_fields")>("./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(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
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(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);

View file

@ -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<AgentInfoViewProps> = ({ 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<AgentInfoViewProps> = ({ agentId, onClose, accessT
<div>
{/* Overview Panel */}
<TabsContent value="overview" keepMounted>
<AgentIdentityDetails
agentId={agentId}
identity={agent.identity}
accessToken={accessToken}
isAdmin={isAdmin}
/>
<DetailList>
<DetailItem label="Agent ID">{agent.agent_id}</DetailItem>
<DetailItem label="Agent Name">{agent.agent_name}</DetailItem>
@ -505,6 +519,8 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
<AgentFormFields showAgentName={true} panels={panels} />
)}
<AgentIdentityFields accessToken={accessToken} />
{discoveryRequest && (
<div className="mt-4">
<AgentCardDiscovery

View file

@ -11,6 +11,11 @@ export type AgentKillSwitchConfig = components["schemas"]["AgentKillSwitchConfig
export type AgentKillSwitchResult = components["schemas"]["AgentKillSwitchResult"];
export interface Agent {
identity?: components["schemas"]["AgentIdentityBinding"] | null;
identity_managed?: boolean;
enabled?: boolean;
execution_mode?: components["schemas"]["AgentResponse"]["execution_mode"];
jwt_auth_configured?: boolean;
agent_id: string;
agent_name: string;
litellm_params: {

View file

@ -67,7 +67,7 @@ export function AgentPermissions({
<div className="space-y-3">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-purple-600" />
<p className="text-sm font-semibold text-foreground">Agents</p>
<p className="text-sm font-semibold text-foreground">Allowed agents to call</p>
<Badge variant="secondary">{totalCount}</Badge>
</div>

View file

@ -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();
});
});

View file

@ -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) {

View file

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