feat(agents): register Entra identities through the agent API

This commit is contained in:
Joshua Valluru 2026-09-26 12:06:12 -07:00
parent 0a03f3d0c8
commit afa0d37119
7 changed files with 1070 additions and 173 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"
@ -3561,6 +3583,60 @@
"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"
],
"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": {
@ -3710,6 +3786,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": {
@ -3910,6 +4034,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": [
{
@ -3924,6 +4061,16 @@
],
"title": "Extra Headers"
},
"identity": {
"anyOf": [
{
"$ref": "#/components/schemas/EntraIdentityConfig"
},
{
"type": "null"
}
]
},
"kill_switch": {
"anyOf": [
{
@ -4445,6 +4592,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```",
@ -4696,6 +4873,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,13 +15,16 @@ 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.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
@ -135,6 +139,39 @@ 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
def _managed_fields(
incoming: Mapping[str, object],
existing: AgentResponse | None,
updated_by: str,
) -> Mapping[str, object]:
result: Final = managed_write_fields(incoming, existing, updated_by)
if isinstance(result, AgentIdentityFailure):
raise_identity_failure(result, 400)
return result
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 +589,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 +616,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 +631,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, **_managed_fields(agent, None, created_by)},
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 +694,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 +733,31 @@ 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,
**_managed_fields(agent, AgentResponse.model_validate(existing_record.model_dump()), updated_by),
"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,
@ -733,7 +777,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 +828,35 @@ 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,
**_managed_fields(
agent,
AgentResponse.model_validate(existing_row.model_dump()) if existing_row else None,
updated_by,
),
},
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(

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,9 +188,15 @@ 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
@ -430,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"],
@ -491,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:
@ -681,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"
@ -783,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"
@ -870,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

@ -7,7 +7,11 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, StrictInt, field
from typing_extensions import ReadOnly, Required, TypedDict
from litellm.types.llms.base import LiteLLMPydanticObjectBase
from litellm.types.proxy.agent_identity import AgentExecutionMode, AgentIdentityBinding
from litellm.types.proxy.agent_identity import (
AgentExecutionMode,
AgentIdentityBinding,
EntraIdentityConfig,
)
if TYPE_CHECKING:
from a2a.types import SendMessageResponse
@ -249,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
@ -264,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") 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
@ -1337,3 +1453,92 @@ async def test_agent_listing_preserves_stored_identity_bindings(bound: bool) ->
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"

View file

@ -1,11 +1,13 @@
import json
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 litellm.constants import REDACTED_BY_LITELM_STRING
@ -21,7 +23,7 @@ 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
@ -98,7 +100,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()
@ -595,6 +597,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"):
@ -666,11 +686,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(
@ -701,11 +719,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(
@ -1143,6 +1159,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",
@ -1421,3 +1574,21 @@ def test_agent_detail_cache_miss_preserves_admin_identity_visibility(role, monke
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

@ -18071,6 +18071,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;
@ -18220,6 +18237,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;
@ -24206,11 +24240,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?: {
@ -30193,6 +30235,32 @@ 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
* @default [
* "user_impersonation"
* ]
*/
required_scopes: string[];
/** Service Principal Id */
service_principal_id?: string | null;
/** Tenant Id */
tenant_id: string;
};
/** EnvironmentReport */
EnvironmentReport: {
/** Config Lines */
@ -35600,6 +35668,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
@ -37674,8 +37764,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?: {
@ -70133,6 +70231,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;
@ -70298,6 +70416,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;