mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
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:
parent
f39c811d34
commit
52b9fa2ba1
28 changed files with 2197 additions and 236 deletions
|
|
@ -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```",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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'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'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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'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'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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
152
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
152
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue