mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
feat(agents): add optional per-agent kill switch webhook (#42841)
This commit is contained in:
parent
1dc3b62dbc
commit
4584958574
37 changed files with 2485 additions and 20 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_AgentsTable" ADD COLUMN IF NOT EXISTS "kill_switch" JSONB;
|
||||
|
|
@ -72,6 +72,7 @@ model LiteLLM_AgentsTable {
|
|||
agent_card_params Json
|
||||
static_headers Json? @default("{}")
|
||||
extra_headers String[] @default([])
|
||||
kill_switch Json?
|
||||
agent_access_groups String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
object_permission_id String?
|
||||
|
|
|
|||
|
|
@ -557,6 +557,8 @@ SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS: Final[float] = float(
|
|||
request_timeout: float = float(os.getenv("REQUEST_TIMEOUT", str(int(DEFAULT_REQUEST_TIMEOUT_SECONDS))))
|
||||
request_timeout_explicitly_set: bool = "REQUEST_TIMEOUT" in os.environ
|
||||
DEFAULT_A2A_AGENT_TIMEOUT: Final[float] = float(os.getenv("DEFAULT_A2A_AGENT_TIMEOUT", 6000)) # 10 minutes
|
||||
AGENT_KILL_SWITCH_TIMEOUT_SECONDS: Final = 10.0
|
||||
AGENT_KILL_SWITCH_RESPONSE_BODY_MAX_CHARS: Final = 2000
|
||||
# Patterns that indicate a localhost/internal URL in A2A agent cards that should be
|
||||
# replaced with the original base_url. This is a common misconfiguration where
|
||||
# developers deploy agents with development URLs in their agent cards.
|
||||
|
|
|
|||
|
|
@ -2392,6 +2392,16 @@
|
|||
],
|
||||
"title": "Extra Headers"
|
||||
},
|
||||
"kill_switch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentKillSwitchConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"litellm_params": {
|
||||
"additionalProperties": true,
|
||||
"title": "Litellm Params",
|
||||
|
|
@ -2561,6 +2571,221 @@
|
|||
"title": "AgentKeySummary",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentKillSwitchApiKeyAuth": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"title": "Api Key",
|
||||
"type": "string"
|
||||
},
|
||||
"header_name": {
|
||||
"default": "x-api-key",
|
||||
"title": "Header Name",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "api_key",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"api_key"
|
||||
],
|
||||
"title": "AgentKillSwitchApiKeyAuth",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentKillSwitchBasicAuth": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"password": {
|
||||
"title": "Password",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "basic",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"username",
|
||||
"password"
|
||||
],
|
||||
"title": "AgentKillSwitchBasicAuth",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentKillSwitchBearerAuth": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"token": {
|
||||
"title": "Token",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "bearer",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"token"
|
||||
],
|
||||
"title": "AgentKillSwitchBearerAuth",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentKillSwitchConfig": {
|
||||
"additionalProperties": false,
|
||||
"description": "Webhook an admin fires to shut an agent down out of band. LiteLLM only\nmakes the call; whatever the endpoint does with it is the agent's business.",
|
||||
"properties": {
|
||||
"auth": {
|
||||
"anyOf": [
|
||||
{
|
||||
"discriminator": {
|
||||
"mapping": {
|
||||
"api_key": "#/components/schemas/AgentKillSwitchApiKeyAuth",
|
||||
"basic": "#/components/schemas/AgentKillSwitchBasicAuth",
|
||||
"bearer": "#/components/schemas/AgentKillSwitchBearerAuth"
|
||||
},
|
||||
"propertyName": "type"
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentKillSwitchBearerAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentKillSwitchApiKeyAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentKillSwitchBasicAuth"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Auth"
|
||||
},
|
||||
"body": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Body"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Headers",
|
||||
"type": "object"
|
||||
},
|
||||
"method": {
|
||||
"default": "POST",
|
||||
"enum": [
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"GET"
|
||||
],
|
||||
"title": "Method",
|
||||
"type": "string"
|
||||
},
|
||||
"query_params": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Query Params",
|
||||
"type": "object"
|
||||
},
|
||||
"url": {
|
||||
"title": "Url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"title": "AgentKillSwitchConfig",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentKillSwitchResult": {
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"title": "Agent Id",
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Error"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"GET"
|
||||
],
|
||||
"title": "Method",
|
||||
"type": "string"
|
||||
},
|
||||
"response_body": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Response Body"
|
||||
},
|
||||
"status_code": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Status Code"
|
||||
},
|
||||
"url": {
|
||||
"title": "Url",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id",
|
||||
"url",
|
||||
"method"
|
||||
],
|
||||
"title": "AgentKillSwitchResult",
|
||||
"type": "object"
|
||||
},
|
||||
"AgentMakePublicResponse": {
|
||||
"properties": {
|
||||
"message": {
|
||||
|
|
@ -2775,6 +3000,16 @@
|
|||
],
|
||||
"title": "Keys"
|
||||
},
|
||||
"kill_switch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentKillSwitchConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"litellm_params": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -3569,6 +3804,16 @@
|
|||
],
|
||||
"title": "Extra Headers"
|
||||
},
|
||||
"kill_switch": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AgentKillSwitchConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"litellm_params": {
|
||||
"additionalProperties": true,
|
||||
"title": "Litellm Params",
|
||||
|
|
@ -4331,6 +4576,54 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/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```",
|
||||
"operationId": "trigger_agent_kill_switch_v1_agents__agent_id__kill_switch_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "agent_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Agent Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AgentKillSwitchResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Trigger Agent Kill Switch",
|
||||
"tags": [
|
||||
"agents"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/agents/{agent_id}/make_public": {
|
||||
"post": {
|
||||
"description": "Make an agent publicly discoverable\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/v1/agents/123e4567-e89b-12d3-a456-426614174000/make_public\" \\\n -H \"Authorization: Bearer <your_api_key>\" \\\n -H \"Content-Type: application/json\"\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```",
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ class LitellmTableNames(str, enum.Enum):
|
|||
CONFIG_TABLE_NAME = "LiteLLM_Config"
|
||||
SSO_CONFIG_TABLE_NAME = "LiteLLM_SSOConfig"
|
||||
UI_SETTINGS_TABLE_NAME = "LiteLLM_UISettings"
|
||||
AGENT_TABLE_NAME = "LiteLLM_AgentsTable"
|
||||
|
||||
|
||||
class Litellm_EntityType(enum.Enum):
|
||||
|
|
@ -578,6 +579,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/v1/agents/{agent_id}",
|
||||
"/v1/agents/make_public",
|
||||
"/v1/agents/{agent_id}/make_public",
|
||||
"/v1/agents/{agent_id}/kill_switch",
|
||||
)
|
||||
|
||||
# Backwards-compat union — virtual keys may be configured with
|
||||
|
|
@ -3688,7 +3690,7 @@ from litellm.models.spend_logs import ( # noqa: E402
|
|||
)
|
||||
from litellm.models.tag import LiteLLM_TagTable as LiteLLM_TagTable # noqa: E402
|
||||
|
||||
AUDIT_ACTIONS = Literal["created", "updated", "deleted", "blocked", "unblocked", "rotated"]
|
||||
AUDIT_ACTIONS = Literal["created", "updated", "deleted", "blocked", "unblocked", "rotated", "kill_switch_fired"]
|
||||
|
||||
|
||||
class LiteLLM_AuditLogs(LiteLLMPydanticObjectBase):
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@ import litellm
|
|||
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.management_helpers.object_permission_utils import (
|
||||
handle_update_object_permission_common,
|
||||
)
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
from litellm.repositories.table_repositories import AgentsRepository, ObjectPermissionRepository
|
||||
from litellm.types.agents import AgentConfig, AgentResponse, PatchAgentRequest
|
||||
from litellm.types.agents import AgentConfig, AgentKillSwitchConfig, AgentResponse, PatchAgentRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import models as prisma_models
|
||||
|
|
@ -31,6 +32,10 @@ class AgentObjectPermissionRecord(Protocol):
|
|||
def dict(self) -> dict[str, object]: ...
|
||||
|
||||
|
||||
class AgentIdWhere(TypedDict):
|
||||
agent_id: ReadOnly[str]
|
||||
|
||||
|
||||
class AgentRecordDump(TypedDict):
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
|
|
@ -38,6 +43,7 @@ class AgentRecordDump(TypedDict):
|
|||
agent_card_params: dict[str, object]
|
||||
static_headers: dict[str, str] | None
|
||||
extra_headers: list[str] | None
|
||||
kill_switch: ReadOnly[AgentKillSwitchConfig | None]
|
||||
access_group_ids: ReadOnly[Sequence[str] | None]
|
||||
object_permission: dict[str, object] | None
|
||||
spend: float
|
||||
|
|
@ -70,6 +76,9 @@ class AgentRecord(Protocol):
|
|||
@property
|
||||
def access_group_ids(self) -> Sequence[str] | None: ...
|
||||
|
||||
@property
|
||||
def kill_switch(self) -> Mapping[str, object] | None: ...
|
||||
|
||||
@property
|
||||
def spend(self) -> float: ...
|
||||
|
||||
|
|
@ -211,6 +220,29 @@ def parse_agent_litellm_params(value: object) -> Mapping[str, object]:
|
|||
return _EMPTY_LITELLM_PARAMS
|
||||
|
||||
|
||||
_KILL_SWITCH_ADAPTER: Final[TypeAdapter[AgentKillSwitchConfig | None]] = TypeAdapter(AgentKillSwitchConfig | None)
|
||||
|
||||
|
||||
def parse_agent_kill_switch(value: object) -> AgentKillSwitchConfig | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
return _KILL_SWITCH_ADAPTER.validate_json(value)
|
||||
return _KILL_SWITCH_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def serialize_agent_kill_switch(incoming: object, existing: object) -> str:
|
||||
"""prisma-client-py drops ``None`` from update data, so a cleared kill switch is stored as the JSON literal
|
||||
``null`` (read back as ``None``), the same convention ``memory_endpoints`` uses for ``Json?`` columns."""
|
||||
restored: Final = restore_kill_switch(
|
||||
_KILL_SWITCH_ADAPTER.validate_python(incoming), parse_agent_kill_switch(existing)
|
||||
)
|
||||
return safe_dumps(restored.model_dump() if restored is not None else None)
|
||||
|
||||
|
||||
_MISSING_AGENT_PARAM: Final = object()
|
||||
_RESTORE_AGENT_PARAMS_MAX_DEPTH: Final = 10
|
||||
|
||||
|
|
@ -293,6 +325,12 @@ def _patched_access_group_ids(agent: PatchAgentRequest) -> Mapping[str, object]:
|
|||
return MappingProxyType({"access_group_ids": tuple(dict.fromkeys(agent.get("access_group_ids") or ()))})
|
||||
|
||||
|
||||
def _patched_kill_switch(agent: PatchAgentRequest, existing: object) -> Mapping[str, object]:
|
||||
if "kill_switch" not in agent:
|
||||
return MappingProxyType({})
|
||||
return MappingProxyType({"kill_switch": serialize_agent_kill_switch(agent.get("kill_switch"), existing)})
|
||||
|
||||
|
||||
def _restore_redacted_litellm_params(
|
||||
incoming: Mapping[str, object],
|
||||
existing: Mapping[str, object],
|
||||
|
|
@ -531,6 +569,7 @@ class AgentRegistry:
|
|||
"agent_name": agent_name,
|
||||
"litellm_params": litellm_params,
|
||||
"agent_card_params": agent_card_params,
|
||||
"kill_switch": serialize_agent_kill_switch(agent.get("kill_switch"), None),
|
||||
"created_by": created_by,
|
||||
"updated_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
|
|
@ -613,7 +652,10 @@ class AgentRegistry:
|
|||
existing_agent: Final[Mapping[str, object]] = dict(existing_record)
|
||||
|
||||
augment_agent: Final = {**existing_agent, **agent}
|
||||
update_data: Final[dict[str, object]] = {**_patched_access_group_ids(agent)}
|
||||
update_data: Final[dict[str, object]] = {
|
||||
**_patched_access_group_ids(agent),
|
||||
**_patched_kill_switch(agent, existing_agent.get("kill_switch")),
|
||||
}
|
||||
if augment_agent.get("agent_name"):
|
||||
update_data["agent_name"] = augment_agent.get("agent_name")
|
||||
if "litellm_params" in agent:
|
||||
|
|
@ -716,6 +758,9 @@ class AgentRegistry:
|
|||
)
|
||||
extra_headers_val_u: Final = agent.get("extra_headers") or []
|
||||
access_group_ids_val_u: Final = tuple(dict.fromkeys(agent.get("access_group_ids") or ()))
|
||||
kill_switch_val_u: Final = serialize_agent_kill_switch(
|
||||
agent.get("kill_switch"), existing_row.kill_switch if existing_row is not None else None
|
||||
)
|
||||
|
||||
update_data: Final[dict[str, object]] = {
|
||||
"agent_name": agent_name,
|
||||
|
|
@ -723,6 +768,7 @@ class AgentRegistry:
|
|||
"agent_card_params": agent_card_params,
|
||||
"static_headers": static_headers_val_u,
|
||||
"extra_headers": extra_headers_val_u,
|
||||
"kill_switch": kill_switch_val_u,
|
||||
"access_group_ids": access_group_ids_val_u,
|
||||
"updated_by": updated_by,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ from litellm.proxy.a2a.agent_card import (
|
|||
normalize_protocol_version,
|
||||
)
|
||||
from litellm.proxy.agent_endpoints.agent_registry import (
|
||||
AgentIdWhere,
|
||||
parse_agent_kill_switch,
|
||||
parse_agent_litellm_params,
|
||||
redact_sensitive_agent_litellm_params,
|
||||
)
|
||||
|
|
@ -45,6 +47,15 @@ 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.kill_switch import (
|
||||
KillSwitchAuditLogWriter,
|
||||
KillSwitchHttpClient,
|
||||
build_kill_switch_audit_log,
|
||||
default_kill_switch_audit_log_writer,
|
||||
default_kill_switch_http_client,
|
||||
fire_kill_switch,
|
||||
redact_kill_switch,
|
||||
)
|
||||
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
|
||||
|
|
@ -53,6 +64,8 @@ from litellm.types.agents import (
|
|||
AgentCard,
|
||||
AgentConfig,
|
||||
AgentKeySummary,
|
||||
AgentKillSwitchConfig,
|
||||
AgentKillSwitchResult,
|
||||
AgentMakePublicResponse,
|
||||
AgentResponse,
|
||||
MakeAgentsPublicRequest,
|
||||
|
|
@ -160,9 +173,10 @@ def _redact_sensitive_agent_fields(
|
|||
) -> list[AgentResponse]:
|
||||
"""
|
||||
Return copies of the given agents with credential-bearing litellm_params
|
||||
values replaced by a fixed marker (never returned to ANY caller,
|
||||
admin included) and, for non-admin callers, virtual-key and header
|
||||
fields stripped entirely. The original objects are not modified.
|
||||
values and kill-switch auth secrets replaced by a fixed marker (never
|
||||
returned to ANY caller, admin included) and, for non-admin callers,
|
||||
virtual-key, header and kill-switch fields stripped entirely. The original
|
||||
objects are not modified.
|
||||
"""
|
||||
redacted: Final[list[AgentResponse]] = []
|
||||
for agent in agents:
|
||||
|
|
@ -171,8 +185,10 @@ def _redact_sensitive_agent_fields(
|
|||
copy.static_headers = None
|
||||
copy.extra_headers = None
|
||||
copy.keys = None
|
||||
copy.kill_switch = 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)
|
||||
redacted.append(copy)
|
||||
return redacted
|
||||
|
||||
|
|
@ -872,6 +888,74 @@ async def delete_agent(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/v1/agents/{agent_id}/kill_switch",
|
||||
tags=["[beta] A2A Agents"], # mutable-ok: fastapi types tags as list[str | Enum]
|
||||
dependencies=(Depends(user_api_key_auth),),
|
||||
response_model=AgentKillSwitchResult,
|
||||
)
|
||||
async def trigger_agent_kill_switch(
|
||||
agent_id: str,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
http_client: Annotated[KillSwitchHttpClient, Depends(default_kill_switch_http_client)],
|
||||
audit_log_writer: Annotated[KillSwitchAuditLogWriter, Depends(default_kill_switch_audit_log_writer)],
|
||||
):
|
||||
"""
|
||||
Fire the agent's configured kill switch webhook. Proxy admin only.
|
||||
|
||||
LiteLLM only makes the configured HTTP call and reports what came back; it
|
||||
does not change the agent's state in LiteLLM. Returns 200 when the webhook
|
||||
answered 2xx, 502 with the same result body otherwise. Every attempt is
|
||||
written to the audit log as a `kill_switch_fired` row against the agent.
|
||||
|
||||
Example Request:
|
||||
```bash
|
||||
curl -X POST "http://localhost:4000/v1/agents/123e4567-e89b-12d3-a456-426614174000/kill_switch" \\
|
||||
-H "Authorization: Bearer <your_api_key>"
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
await check_feature_access_for_user(user_api_key_dict, "agents")
|
||||
_check_agent_management_permission(user_api_key_dict)
|
||||
|
||||
resolved: Final = await _resolve_agent_kill_switch(agent_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent with ID {agent_id} not found")
|
||||
resolved_agent_id, config = resolved
|
||||
if config is None:
|
||||
raise HTTPException(status_code=400, detail=f"Agent with ID {agent_id} has no kill_switch configured")
|
||||
|
||||
result: Final = await fire_kill_switch(agent_id=resolved_agent_id, config=config, http_client=http_client)
|
||||
await audit_log_writer(
|
||||
build_kill_switch_audit_log(
|
||||
result=result,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
)
|
||||
)
|
||||
if not result.succeeded:
|
||||
raise HTTPException(status_code=502, detail=result.model_dump())
|
||||
return result
|
||||
|
||||
|
||||
async def _resolve_agent_kill_switch(agent_id: str) -> tuple[str, AgentKillSwitchConfig | None] | None:
|
||||
"""The DB row wins over this replica's in-memory registry so a trigger never fires a webhook another
|
||||
replica has since changed; config.yaml agents have no row and fall back to the registry."""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is not None:
|
||||
where: Final[AgentIdWhere] = {"agent_id": agent_id}
|
||||
row: Final = await agents_table(prisma_client).find_unique(where=where)
|
||||
if row is not None:
|
||||
return row.agent_id, parse_agent_kill_switch(row.kill_switch)
|
||||
|
||||
agent: Final = AGENT_REGISTRY.get_agent_by_id(agent_id=agent_id)
|
||||
if agent is None:
|
||||
return None
|
||||
return agent.agent_id, agent.kill_switch
|
||||
|
||||
|
||||
@router.post(
|
||||
"/v1/agents/{agent_id}/make_public",
|
||||
tags=["[beta] A2A Agents"],
|
||||
|
|
|
|||
239
litellm/proxy/agent_endpoints/kill_switch.py
Normal file
239
litellm/proxy/agent_endpoints/kill_switch.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
from base64 import b64encode
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypeAlias
|
||||
|
||||
import httpx
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
AGENT_KILL_SWITCH_RESPONSE_BODY_MAX_CHARS,
|
||||
AGENT_KILL_SWITCH_TIMEOUT_SECONDS,
|
||||
REDACTED_BY_LITELM_STRING,
|
||||
)
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # its params arg is a bare dict in http_handler
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, UserAPIKeyAuth
|
||||
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update, get_audit_log_changed_by
|
||||
from litellm.types.agents import (
|
||||
AgentKillSwitchApiKeyAuth,
|
||||
AgentKillSwitchAuth,
|
||||
AgentKillSwitchBasicAuth,
|
||||
AgentKillSwitchBearerAuth,
|
||||
AgentKillSwitchConfig,
|
||||
AgentKillSwitchResult,
|
||||
)
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
|
||||
def _with_auth(config: AgentKillSwitchConfig, auth: AgentKillSwitchAuth) -> AgentKillSwitchConfig:
|
||||
return AgentKillSwitchConfig(
|
||||
url=config.url,
|
||||
method=config.method,
|
||||
headers=config.headers,
|
||||
query_params=config.query_params,
|
||||
body=config.body,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
||||
def redact_kill_switch(config: AgentKillSwitchConfig | None) -> AgentKillSwitchConfig | None:
|
||||
if config is None or config.auth is None:
|
||||
return config
|
||||
return _with_auth(config, _redact_auth(config.auth))
|
||||
|
||||
|
||||
def _redact_auth(auth: AgentKillSwitchAuth) -> AgentKillSwitchAuth:
|
||||
match auth:
|
||||
case AgentKillSwitchBearerAuth():
|
||||
return AgentKillSwitchBearerAuth(type="bearer", token=REDACTED_BY_LITELM_STRING)
|
||||
case AgentKillSwitchApiKeyAuth():
|
||||
return AgentKillSwitchApiKeyAuth(
|
||||
type="api_key", header_name=auth.header_name, api_key=REDACTED_BY_LITELM_STRING
|
||||
)
|
||||
case AgentKillSwitchBasicAuth():
|
||||
return AgentKillSwitchBasicAuth(type="basic", username=auth.username, password=REDACTED_BY_LITELM_STRING)
|
||||
case _:
|
||||
assert_never(auth)
|
||||
|
||||
|
||||
def restore_kill_switch(
|
||||
incoming: AgentKillSwitchConfig | None,
|
||||
existing: AgentKillSwitchConfig | None,
|
||||
) -> AgentKillSwitchConfig | None:
|
||||
"""Put the stored secret back behind an auth field echoed as the redaction
|
||||
marker; a marker with no stored secret of the same auth type becomes ""."""
|
||||
if incoming is None or incoming.auth is None:
|
||||
return incoming
|
||||
existing_auth: Final = existing.auth if existing is not None else None
|
||||
return _with_auth(incoming, _restore_auth(incoming.auth, existing_auth))
|
||||
|
||||
|
||||
def _restore_secret(incoming_value: str, existing_value: str | None) -> str:
|
||||
if incoming_value != REDACTED_BY_LITELM_STRING:
|
||||
return incoming_value
|
||||
return existing_value if existing_value is not None else ""
|
||||
|
||||
|
||||
def _restore_auth(incoming: AgentKillSwitchAuth, existing: AgentKillSwitchAuth | None) -> AgentKillSwitchAuth:
|
||||
match incoming:
|
||||
case AgentKillSwitchBearerAuth():
|
||||
stored_token: Final = existing.token if isinstance(existing, AgentKillSwitchBearerAuth) else None
|
||||
return AgentKillSwitchBearerAuth(type="bearer", token=_restore_secret(incoming.token, stored_token))
|
||||
case AgentKillSwitchApiKeyAuth():
|
||||
stored_key: Final = existing.api_key if isinstance(existing, AgentKillSwitchApiKeyAuth) else None
|
||||
return AgentKillSwitchApiKeyAuth(
|
||||
type="api_key",
|
||||
header_name=incoming.header_name,
|
||||
api_key=_restore_secret(incoming.api_key, stored_key),
|
||||
)
|
||||
case AgentKillSwitchBasicAuth():
|
||||
stored_password: Final = existing.password if isinstance(existing, AgentKillSwitchBasicAuth) else None
|
||||
return AgentKillSwitchBasicAuth(
|
||||
type="basic",
|
||||
username=incoming.username,
|
||||
password=_restore_secret(incoming.password, stored_password),
|
||||
)
|
||||
case _:
|
||||
assert_never(incoming)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KillSwitchRequest:
|
||||
method: str
|
||||
url: str
|
||||
headers: Mapping[str, str]
|
||||
json_body: Mapping[str, object] | None
|
||||
|
||||
|
||||
def _auth_headers(auth: AgentKillSwitchAuth | None) -> Mapping[str, str]:
|
||||
match auth:
|
||||
case None:
|
||||
return MappingProxyType({})
|
||||
case AgentKillSwitchBearerAuth():
|
||||
return MappingProxyType({"Authorization": f"Bearer {auth.token}"})
|
||||
case AgentKillSwitchApiKeyAuth():
|
||||
return MappingProxyType({auth.header_name: auth.api_key})
|
||||
case AgentKillSwitchBasicAuth():
|
||||
credentials: Final = b64encode(f"{auth.username}:{auth.password}".encode()).decode()
|
||||
return MappingProxyType({"Authorization": f"Basic {credentials}"})
|
||||
case _:
|
||||
assert_never(auth)
|
||||
|
||||
|
||||
def build_kill_switch_request(config: AgentKillSwitchConfig) -> KillSwitchRequest:
|
||||
url: Final = httpx.URL(config.url).copy_merge_params(config.query_params)
|
||||
return KillSwitchRequest(
|
||||
method=config.method,
|
||||
url=str(url),
|
||||
headers=MappingProxyType({**config.headers, **_auth_headers(config.auth)}),
|
||||
json_body=config.body,
|
||||
)
|
||||
|
||||
|
||||
class KillSwitchHttpClient(Protocol):
|
||||
def build_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Mapping[str, str],
|
||||
json: Mapping[str, object] | None,
|
||||
timeout: float,
|
||||
) -> httpx.Request: ...
|
||||
|
||||
async def send(self, request: httpx.Request, *, stream: bool, follow_redirects: bool) -> httpx.Response: ...
|
||||
|
||||
|
||||
def default_kill_switch_http_client() -> KillSwitchHttpClient:
|
||||
return get_async_httpx_client(llm_provider=httpxSpecialProvider.AgentKillSwitch).client
|
||||
|
||||
|
||||
KillSwitchAuditLogWriter: TypeAlias = Callable[[LiteLLM_AuditLogs], Awaitable[None]] # mutable-ok: Callable params
|
||||
|
||||
|
||||
def default_kill_switch_audit_log_writer() -> KillSwitchAuditLogWriter:
|
||||
return create_audit_log_for_update
|
||||
|
||||
|
||||
def build_kill_switch_audit_log(
|
||||
*,
|
||||
result: AgentKillSwitchResult,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_proxy_admin_name: str | None,
|
||||
) -> LiteLLM_AuditLogs:
|
||||
return LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=None,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.AGENT_TABLE_NAME,
|
||||
object_id=result.agent_id,
|
||||
action="kill_switch_fired",
|
||||
updated_values=result.model_dump_json(exclude_none=True),
|
||||
)
|
||||
|
||||
|
||||
async def fire_kill_switch(
|
||||
*,
|
||||
agent_id: str,
|
||||
config: AgentKillSwitchConfig,
|
||||
http_client: KillSwitchHttpClient,
|
||||
timeout: float = AGENT_KILL_SWITCH_TIMEOUT_SECONDS,
|
||||
) -> AgentKillSwitchResult:
|
||||
request: Final = build_kill_switch_request(config)
|
||||
reported_url: Final = str(httpx.URL(request.url).copy_with(query=None))
|
||||
verbose_proxy_logger.info("Firing kill switch for agent %s: %s %s", agent_id, request.method, reported_url)
|
||||
try:
|
||||
response: Final = await http_client.send(
|
||||
http_client.build_request(
|
||||
request.method,
|
||||
request.url,
|
||||
headers=request.headers,
|
||||
json=request.json_body,
|
||||
timeout=timeout,
|
||||
),
|
||||
stream=True,
|
||||
follow_redirects=False,
|
||||
)
|
||||
body: Final = await _read_text_prefix(response, AGENT_KILL_SWITCH_RESPONSE_BODY_MAX_CHARS)
|
||||
except httpx.HTTPError as exc:
|
||||
verbose_proxy_logger.warning("Kill switch for agent %s failed: %s", agent_id, type(exc).__name__)
|
||||
return AgentKillSwitchResult(
|
||||
agent_id=agent_id,
|
||||
url=reported_url,
|
||||
method=config.method,
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
return AgentKillSwitchResult(
|
||||
agent_id=agent_id,
|
||||
url=reported_url,
|
||||
method=config.method,
|
||||
status_code=response.status_code,
|
||||
response_body=body,
|
||||
)
|
||||
|
||||
|
||||
async def _read_text_prefix(response: httpx.Response, max_chars: int) -> str:
|
||||
try:
|
||||
return await _take_text(response.aiter_text(), max_chars)
|
||||
finally:
|
||||
await response.aclose()
|
||||
|
||||
|
||||
async def _take_text(chunks: AsyncIterator[str], max_chars: int) -> str:
|
||||
taken = "" # rebind-ok: running prefix of a stream that is abandoned once the cap is hit
|
||||
async for chunk in chunks:
|
||||
taken += chunk # rebind-ok: see above
|
||||
if len(taken) >= max_chars:
|
||||
break
|
||||
return taken[:max_chars]
|
||||
|
|
@ -72,6 +72,7 @@ model LiteLLM_AgentsTable {
|
|||
agent_card_params Json
|
||||
static_headers Json? @default("{}")
|
||||
extra_headers String[] @default([])
|
||||
kill_switch Json?
|
||||
agent_access_groups String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
object_permission_id String?
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, TypeAlias
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, PrivateAttr, StrictInt
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, StrictInt, field_validator
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
|
||||
from litellm.types.llms.base import LiteLLMPydanticObjectBase
|
||||
|
|
@ -178,6 +179,74 @@ class AgentObjectPermission(TypedDict, total=False):
|
|||
agents: list[str] | None
|
||||
|
||||
|
||||
class AgentKillSwitchBearerAuth(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
type: Literal["bearer"]
|
||||
token: str
|
||||
|
||||
|
||||
class AgentKillSwitchApiKeyAuth(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
type: Literal["api_key"]
|
||||
header_name: str = "x-api-key"
|
||||
api_key: str
|
||||
|
||||
|
||||
class AgentKillSwitchBasicAuth(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
type: Literal["basic"]
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
AgentKillSwitchAuth: TypeAlias = Annotated[
|
||||
AgentKillSwitchBearerAuth | AgentKillSwitchApiKeyAuth | AgentKillSwitchBasicAuth,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
AgentKillSwitchMethod: TypeAlias = Literal["POST", "PUT", "PATCH", "DELETE", "GET"]
|
||||
|
||||
|
||||
class AgentKillSwitchConfig(BaseModel):
|
||||
"""Webhook an admin fires to shut an agent down out of band. LiteLLM only
|
||||
makes the call; whatever the endpoint does with it is the agent's business."""
|
||||
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
url: str
|
||||
method: AgentKillSwitchMethod = "POST"
|
||||
headers: Mapping[str, str] = Field(default_factory=dict)
|
||||
query_params: Mapping[str, str] = Field(default_factory=dict)
|
||||
body: Mapping[str, object] | None = None
|
||||
auth: AgentKillSwitchAuth | None = None
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _require_absolute_http_url(cls, value: str) -> str:
|
||||
parts: Final = urlsplit(value)
|
||||
if parts.scheme not in ("http", "https") or not parts.netloc:
|
||||
raise ValueError("kill_switch.url must be an absolute http(s) URL")
|
||||
return value
|
||||
|
||||
|
||||
class AgentKillSwitchResult(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
agent_id: str
|
||||
url: str
|
||||
method: AgentKillSwitchMethod
|
||||
status_code: int | None = None
|
||||
response_body: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.status_code is not None and 200 <= self.status_code < 300
|
||||
|
||||
|
||||
class AgentConfig(TypedDict, total=False):
|
||||
agent_name: Required[str]
|
||||
agent_card_params: Required[AgentCard]
|
||||
|
|
@ -190,6 +259,7 @@ class AgentConfig(TypedDict, total=False):
|
|||
static_headers: dict[str, str] | None
|
||||
extra_headers: list[str] | None
|
||||
access_group_ids: ReadOnly[Sequence[str] | None]
|
||||
kill_switch: ReadOnly[AgentKillSwitchConfig | None]
|
||||
|
||||
|
||||
class PatchAgentRequest(TypedDict, total=False):
|
||||
|
|
@ -204,6 +274,7 @@ class PatchAgentRequest(TypedDict, total=False):
|
|||
static_headers: dict[str, str] | None
|
||||
extra_headers: list[str] | None
|
||||
access_group_ids: ReadOnly[Sequence[str] | None]
|
||||
kill_switch: ReadOnly[AgentKillSwitchConfig | None]
|
||||
|
||||
|
||||
AGENT_CALLER_USER_ID_HEADER: Final = "x-litellm-user-id"
|
||||
|
|
@ -243,6 +314,7 @@ class AgentResponse(BaseModel):
|
|||
static_headers: dict[str, str] | None = None
|
||||
extra_headers: list[str] | None = None
|
||||
access_group_ids: Sequence[str] | None = None
|
||||
kill_switch: AgentKillSwitchConfig | None = None
|
||||
keys: list[AgentKeySummary] | None = None
|
||||
search_score: float | None = None
|
||||
created_at: datetime | None = None
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class httpxSpecialProvider(str, Enum):
|
|||
RAG = "rag"
|
||||
A2AProvider = "a2a_provider"
|
||||
AgentHealthCheck = "agent_health_check"
|
||||
AgentKillSwitch = "agent_kill_switch"
|
||||
A2A = "a2a"
|
||||
PromptManagement = "prompt_management"
|
||||
UI = "ui"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ model LiteLLM_AgentsTable {
|
|||
agent_card_params Json
|
||||
static_headers Json? @default("{}")
|
||||
extra_headers String[] @default([])
|
||||
kill_switch Json?
|
||||
agent_access_groups String[] @default([])
|
||||
access_group_ids String[] @default([])
|
||||
object_permission_id String?
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@ 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)
|
||||
return_value=SimpleNamespace(litellm_params={}, object_permission_id=None, kill_switch=None)
|
||||
)
|
||||
mock_prisma.db.litellm_agentstable.update = AsyncMock(return_value=None)
|
||||
|
||||
|
|
@ -736,6 +736,7 @@ async def test_update_agent_in_db_preserves_secret_when_echoed_back_redacted():
|
|||
"model": "bedrock/agentcore/my-agent",
|
||||
},
|
||||
object_permission_id=None,
|
||||
kill_switch=None,
|
||||
)
|
||||
)
|
||||
updated_agent = MagicMock()
|
||||
|
|
@ -784,6 +785,7 @@ async def test_update_agent_in_db_preserves_secret_when_key_omitted_entirely():
|
|||
return_value=SimpleNamespace(
|
||||
litellm_params={"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
|
||||
object_permission_id=None,
|
||||
kill_switch=None,
|
||||
)
|
||||
)
|
||||
updated_agent = MagicMock()
|
||||
|
|
@ -830,6 +832,7 @@ async def test_update_agent_in_db_preserves_secret_nested_under_a_non_sensitive_
|
|||
}
|
||||
},
|
||||
object_permission_id=None,
|
||||
kill_switch=None,
|
||||
)
|
||||
)
|
||||
updated_agent = MagicMock()
|
||||
|
|
@ -878,6 +881,7 @@ async def test_update_agent_in_db_clears_secret_on_explicit_empty_value():
|
|||
return_value=SimpleNamespace(
|
||||
litellm_params={"aws_secret_access_key": SENTINEL_AWS_SECRET_ACCESS_KEY},
|
||||
object_permission_id=None,
|
||||
kill_switch=None,
|
||||
)
|
||||
)
|
||||
updated_agent = MagicMock()
|
||||
|
|
@ -1110,7 +1114,9 @@ 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, access_group_ids=["ag-1"])
|
||||
return_value=SimpleNamespace(
|
||||
litellm_params={}, object_permission_id=None, kill_switch=None, access_group_ids=["ag-1"]
|
||||
)
|
||||
)
|
||||
mock_update = AsyncMock(return_value=_agent_row_mock(expected))
|
||||
mock_prisma.db.litellm_agentstable.update = mock_update
|
||||
|
|
@ -1126,3 +1132,155 @@ 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)
|
||||
|
||||
|
||||
_KILL_SWITCH: Final = {
|
||||
"url": "https://ops.example.com/kill",
|
||||
"method": "POST",
|
||||
"headers": {"X-Env": "prod"},
|
||||
"query_params": {"reason": "manual"},
|
||||
"body": {"action": "stop"},
|
||||
"auth": {"type": "bearer", "token": "tok-real"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_agent_to_db_stores_kill_switch_json_and_a_json_null_when_unset():
|
||||
registry: Final = AgentRegistry()
|
||||
mock_prisma: Final = MagicMock()
|
||||
mock_create = AsyncMock(return_value=_agent_row_mock([]))
|
||||
mock_prisma.db.litellm_agentstable.create = mock_create
|
||||
|
||||
await registry.add_agent_to_db(
|
||||
agent={
|
||||
"agent_name": "Test Agent",
|
||||
"agent_card_params": _sample_agent_card_params(),
|
||||
"kill_switch": _KILL_SWITCH,
|
||||
},
|
||||
prisma_client=mock_prisma,
|
||||
created_by="test-user",
|
||||
)
|
||||
assert json.loads(mock_create.call_args.kwargs["data"]["kill_switch"]) == _KILL_SWITCH
|
||||
|
||||
await registry.add_agent_to_db(
|
||||
agent={"agent_name": "Plain Agent", "agent_card_params": _sample_agent_card_params()},
|
||||
prisma_client=mock_prisma,
|
||||
created_by="test-user",
|
||||
)
|
||||
assert mock_create.call_args.kwargs["data"]["kill_switch"] == json.dumps(None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_agent_to_db_rejects_a_kill_switch_with_a_non_http_url():
|
||||
registry: Final = AgentRegistry()
|
||||
mock_prisma: Final = MagicMock()
|
||||
mock_prisma.db.litellm_agentstable.create = AsyncMock(return_value=_agent_row_mock([]))
|
||||
|
||||
with pytest.raises(Exception, match="absolute http"):
|
||||
await registry.add_agent_to_db(
|
||||
agent={
|
||||
"agent_name": "Test Agent",
|
||||
"agent_card_params": _sample_agent_card_params(),
|
||||
"kill_switch": {**_KILL_SWITCH, "url": "ops.example.com/kill"},
|
||||
},
|
||||
prisma_client=mock_prisma,
|
||||
created_by="test-user",
|
||||
)
|
||||
mock_prisma.db.litellm_agentstable.create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_agent_in_db_keeps_kill_switch_when_omitted_and_clears_it_on_null():
|
||||
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,
|
||||
}
|
||||
)
|
||||
mock_update = AsyncMock(return_value=_agent_row_mock([]))
|
||||
mock_prisma.db.litellm_agentstable.update = mock_update
|
||||
|
||||
await registry.patch_agent_in_db(
|
||||
agent_id="agent-123", agent={"agent_name": "New"}, prisma_client=mock_prisma, updated_by="u"
|
||||
)
|
||||
assert "kill_switch" not in mock_update.call_args.kwargs["data"]
|
||||
|
||||
await registry.patch_agent_in_db(
|
||||
agent_id="agent-123", agent={"kill_switch": None}, prisma_client=mock_prisma, updated_by="u"
|
||||
)
|
||||
assert mock_update.call_args.kwargs["data"]["kill_switch"] == json.dumps(None), (
|
||||
"prisma-client-py silently drops None, so the clear must be written as the JSON literal null"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_agent_in_db_restores_the_stored_kill_switch_secret_behind_the_marker():
|
||||
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,
|
||||
}
|
||||
)
|
||||
mock_update = AsyncMock(return_value=_agent_row_mock([]))
|
||||
mock_prisma.db.litellm_agentstable.update = mock_update
|
||||
|
||||
await registry.patch_agent_in_db(
|
||||
agent_id="agent-123",
|
||||
agent={
|
||||
"kill_switch": {
|
||||
**_KILL_SWITCH,
|
||||
"url": "https://ops.example.com/v2/kill",
|
||||
"auth": {"type": "bearer", "token": REDACTED_BY_LITELM_STRING},
|
||||
}
|
||||
},
|
||||
prisma_client=mock_prisma,
|
||||
updated_by="u",
|
||||
)
|
||||
|
||||
assert json.loads(mock_update.call_args.kwargs["data"]["kill_switch"]) == {
|
||||
**_KILL_SWITCH,
|
||||
"url": "https://ops.example.com/v2/kill",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_agent_in_db_clears_kill_switch_when_omitted_and_restores_secret_when_echoed():
|
||||
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))
|
||||
)
|
||||
mock_update = AsyncMock(return_value=_agent_row_mock([]))
|
||||
mock_prisma.db.litellm_agentstable.update = mock_update
|
||||
base: Final = {"agent_name": "Test Agent", "agent_card_params": _sample_agent_card_params(), "litellm_params": {}}
|
||||
|
||||
await registry.update_agent_in_db(agent_id="agent-123", agent=base, prisma_client=mock_prisma, updated_by="u")
|
||||
assert mock_update.call_args.kwargs["data"]["kill_switch"] == json.dumps(None)
|
||||
|
||||
echoed: Final = {**_KILL_SWITCH, "auth": {"type": "bearer", "token": REDACTED_BY_LITELM_STRING}}
|
||||
await registry.update_agent_in_db(
|
||||
agent_id="agent-123", agent={**base, "kill_switch": echoed}, prisma_client=mock_prisma, updated_by="u"
|
||||
)
|
||||
assert json.loads(mock_update.call_args.kwargs["data"]["kill_switch"]) == _KILL_SWITCH
|
||||
|
||||
|
||||
def test_load_agents_from_config_exposes_a_typed_kill_switch():
|
||||
registry: Final = AgentRegistry()
|
||||
|
||||
registry.load_agents_from_config(
|
||||
[{"agent_name": "cfg-agent", "agent_card_params": _sample_agent_card_params(), "kill_switch": _KILL_SWITCH}]
|
||||
)
|
||||
|
||||
(agent,) = registry.get_agent_list()
|
||||
assert agent.kill_switch is not None
|
||||
assert agent.kill_switch.model_dump() == _KILL_SWITCH
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import json
|
||||
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.testclient import TestClient
|
||||
|
||||
from litellm.constants import REDACTED_BY_LITELM_STRING
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.agent_endpoints import endpoints as agent_endpoints
|
||||
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
|
||||
RestrictedAgentAccess,
|
||||
|
|
@ -1136,3 +1138,222 @@ def test_make_agent_public_rejects_an_agent_published_only_in_the_db(monkeypatch
|
|||
|
||||
assert duplicate.status_code == 400
|
||||
assert "already in public agent groups" in duplicate.json()["detail"]
|
||||
|
||||
|
||||
_KILL_SWITCH: Final = {
|
||||
"url": "https://ops.example.com/kill",
|
||||
"method": "POST",
|
||||
"headers": {"X-Env": "prod"},
|
||||
"query_params": {"reason": "manual"},
|
||||
"body": {"action": "stop"},
|
||||
"auth": {"type": "bearer", "token": "tok-real"},
|
||||
}
|
||||
|
||||
|
||||
def _agent_with_kill_switch() -> AgentResponse:
|
||||
return AgentResponse(
|
||||
agent_id="agent-123",
|
||||
agent_name="Test Agent",
|
||||
agent_card_params=_sample_agent_card_params(),
|
||||
litellm_params={},
|
||||
kill_switch=_KILL_SWITCH,
|
||||
)
|
||||
|
||||
|
||||
class _FakeKillSwitchClient:
|
||||
def __init__(self, response: httpx.Response) -> None:
|
||||
self.calls: list[tuple[str, str, dict[str, str], object, float]] = [] # mutable-ok: test double records calls
|
||||
self._response: Final = response
|
||||
|
||||
def build_request(self, method: str, url: str, *, headers, json, timeout: float) -> httpx.Request:
|
||||
self.calls.append((method, url, dict(headers), json, timeout))
|
||||
return httpx.Request(method, url, headers=dict(headers), json=json)
|
||||
|
||||
async def send(self, request: httpx.Request, *, stream: bool, follow_redirects: bool) -> httpx.Response:
|
||||
return self._response
|
||||
|
||||
|
||||
class _AuditLogRecorder:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[LiteLLM_AuditLogs] = [] # mutable-ok: test double records writes
|
||||
|
||||
async def __call__(self, request_data: LiteLLM_AuditLogs) -> None:
|
||||
self.rows.append(request_data)
|
||||
|
||||
|
||||
def _kill_switch_app(
|
||||
role: LitellmUserRoles,
|
||||
http_client: _FakeKillSwitchClient,
|
||||
audit_log: _AuditLogRecorder | None = None,
|
||||
) -> TestClient:
|
||||
test_client: Final = _make_app_with_role(role)
|
||||
test_client.app.dependency_overrides[agent_endpoints.default_kill_switch_http_client] = lambda: http_client
|
||||
test_client.app.dependency_overrides[agent_endpoints.default_kill_switch_audit_log_writer] = (
|
||||
lambda: audit_log or _AuditLogRecorder()
|
||||
)
|
||||
return test_client
|
||||
|
||||
|
||||
def test_kill_switch_trigger_fires_the_configured_webhook_and_returns_the_result(monkeypatch) -> None:
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=_agent_with_kill_switch())
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
fake: Final = _FakeKillSwitchClient(httpx.Response(200, text="ok"))
|
||||
|
||||
resp: Final = _kill_switch_app(LitellmUserRoles.PROXY_ADMIN, fake).post(
|
||||
"/v1/agents/agent-123/kill_switch", headers={"Authorization": "Bearer k"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {
|
||||
"agent_id": "agent-123",
|
||||
"url": "https://ops.example.com/kill",
|
||||
"method": "POST",
|
||||
"status_code": 200,
|
||||
"response_body": "ok",
|
||||
"error": None,
|
||||
}
|
||||
(method, url, headers, body, _timeout) = fake.calls[0]
|
||||
assert (method, url, body) == ("POST", "https://ops.example.com/kill?reason=manual", {"action": "stop"})
|
||||
assert headers == {"X-Env": "prod", "Authorization": "Bearer tok-real"}
|
||||
|
||||
|
||||
def test_kill_switch_trigger_writes_an_audit_log_row_naming_the_admin_and_the_sanitized_result(monkeypatch) -> None:
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=_agent_with_kill_switch())
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
fake: Final = _FakeKillSwitchClient(httpx.Response(202, text='{"stopped": true}'))
|
||||
audit: Final = _AuditLogRecorder()
|
||||
test_client: Final = _kill_switch_app(LitellmUserRoles.PROXY_ADMIN, fake, audit)
|
||||
test_client.app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="test-user", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="hashed-k"
|
||||
)
|
||||
|
||||
resp: Final = test_client.post("/v1/agents/agent-123/kill_switch", headers={"Authorization": "Bearer k"})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
(row,) = audit.rows
|
||||
assert (row.action, row.table_name, row.object_id) == (
|
||||
"kill_switch_fired",
|
||||
LitellmTableNames.AGENT_TABLE_NAME,
|
||||
"agent-123",
|
||||
)
|
||||
assert (row.changed_by, row.changed_by_api_key) == ("test-user", "hashed-k")
|
||||
assert row.before_value is None
|
||||
assert json.loads(row.updated_values) == {
|
||||
"agent_id": "agent-123",
|
||||
"url": "https://ops.example.com/kill",
|
||||
"method": "POST",
|
||||
"status_code": 202,
|
||||
"response_body": '{"stopped": true}',
|
||||
}
|
||||
assert "tok-real" not in row.model_dump_json()
|
||||
|
||||
|
||||
def test_kill_switch_trigger_returns_502_and_still_audits_when_the_webhook_rejects(monkeypatch) -> None:
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=_agent_with_kill_switch())
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
fake: Final = _FakeKillSwitchClient(httpx.Response(401, text="bad token"))
|
||||
audit: Final = _AuditLogRecorder()
|
||||
|
||||
resp: Final = _kill_switch_app(LitellmUserRoles.PROXY_ADMIN, fake, audit).post(
|
||||
"/v1/agents/agent-123/kill_switch", headers={"Authorization": "Bearer k"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 502, resp.text
|
||||
assert resp.json()["detail"]["status_code"] == 401
|
||||
assert resp.json()["detail"]["response_body"] == "bad token"
|
||||
(row,) = audit.rows
|
||||
assert row.action == "kill_switch_fired"
|
||||
assert json.loads(row.updated_values)["status_code"] == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
|
||||
def test_kill_switch_trigger_is_refused_before_any_webhook_call_for_non_admins(monkeypatch, role) -> None:
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=_agent_with_kill_switch())
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
fake: Final = _FakeKillSwitchClient(httpx.Response(200))
|
||||
audit: Final = _AuditLogRecorder()
|
||||
|
||||
resp: Final = _kill_switch_app(role, fake, audit).post(
|
||||
"/v1/agents/agent-123/kill_switch", headers={"Authorization": "Bearer k"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert fake.calls == []
|
||||
assert audit.rows == []
|
||||
|
||||
|
||||
def test_kill_switch_trigger_404s_unknown_agent_and_400s_an_agent_without_one(monkeypatch) -> None:
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(side_effect=[None, _sample_agent_response()])
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
fake: Final = _FakeKillSwitchClient(httpx.Response(200))
|
||||
audit: Final = _AuditLogRecorder()
|
||||
test_client: Final = _kill_switch_app(LitellmUserRoles.PROXY_ADMIN, fake, audit)
|
||||
|
||||
missing: Final = test_client.post("/v1/agents/nope/kill_switch", headers={"Authorization": "Bearer k"})
|
||||
unconfigured: Final = test_client.post("/v1/agents/agent-123/kill_switch", headers={"Authorization": "Bearer k"})
|
||||
|
||||
assert missing.status_code == 404
|
||||
assert unconfigured.status_code == 400
|
||||
assert "no kill_switch configured" in unconfigured.json()["detail"]
|
||||
assert fake.calls == []
|
||||
assert audit.rows == []
|
||||
|
||||
|
||||
def test_kill_switch_trigger_fires_the_db_row_config_over_a_stale_in_memory_copy(monkeypatch) -> None:
|
||||
"""Another replica may have updated the agent; the row is the source of truth for what gets fired."""
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=_agent_with_kill_switch())
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
db_row: Final = SimpleNamespace(
|
||||
agent_id="agent-123",
|
||||
kill_switch={"url": "https://ops.example.com/kill-v2", "method": "DELETE", "auth": None},
|
||||
)
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.litellm_agentstable.find_unique = AsyncMock(return_value=db_row)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
fake: Final = _FakeKillSwitchClient(httpx.Response(204))
|
||||
|
||||
resp: Final = _kill_switch_app(LitellmUserRoles.PROXY_ADMIN, fake).post(
|
||||
"/v1/agents/agent-123/kill_switch", headers={"Authorization": "Bearer k"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
(method, url, headers, body, _timeout) = fake.calls[0]
|
||||
assert (method, url, headers, body) == ("DELETE", "https://ops.example.com/kill-v2", {}, None)
|
||||
assert prisma.db.litellm_agentstable.find_unique.await_args.kwargs == {"where": {"agent_id": "agent-123"}}
|
||||
registry.get_agent_by_id.assert_not_called()
|
||||
|
||||
|
||||
def test_get_agent_redacts_kill_switch_secret_for_admins_and_hides_it_from_others(monkeypatch) -> None:
|
||||
registry: Final = MagicMock()
|
||||
registry.get_agent_by_id = MagicMock(return_value=_agent_with_kill_switch())
|
||||
registry.ids_for_agent = MagicMock(return_value=("agent-123",))
|
||||
monkeypatch.setattr(agent_endpoints, "AGENT_REGISTRY", registry)
|
||||
|
||||
def _get_as(role: LitellmUserRoles):
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
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"})
|
||||
|
||||
admin: Final = _get_as(LitellmUserRoles.PROXY_ADMIN)
|
||||
assert admin.status_code == 200, admin.text
|
||||
assert admin.json()["kill_switch"] == {
|
||||
**_KILL_SWITCH,
|
||||
"auth": {"type": "bearer", "token": REDACTED_BY_LITELM_STRING},
|
||||
}
|
||||
|
||||
internal: Final = _get_as(LitellmUserRoles.INTERNAL_USER)
|
||||
assert internal.status_code == 200, internal.text
|
||||
assert internal.json()["kill_switch"] is None
|
||||
assert "tok-real" not in internal.text
|
||||
|
|
|
|||
248
tests/test_litellm/proxy/agent_endpoints/test_kill_switch.py
Normal file
248
tests/test_litellm/proxy/agent_endpoints/test_kill_switch.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
from base64 import b64encode
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from litellm.constants import REDACTED_BY_LITELM_STRING
|
||||
from litellm.proxy.agent_endpoints.kill_switch import (
|
||||
build_kill_switch_request,
|
||||
fire_kill_switch,
|
||||
redact_kill_switch,
|
||||
restore_kill_switch,
|
||||
)
|
||||
from litellm.types.agents import AgentKillSwitchConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SentRequest:
|
||||
method: str
|
||||
url: str
|
||||
headers: Mapping[str, str]
|
||||
json: Mapping[str, object] | None
|
||||
timeout: float
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
def __init__(self, respond: httpx.Response | httpx.HTTPError) -> None:
|
||||
self.sent: list[_SentRequest] = [] # mutable-ok: test double records calls
|
||||
self.follow_redirects: list[bool] = [] # mutable-ok: test double records calls
|
||||
self._respond: Final = respond
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Mapping[str, str],
|
||||
json: Mapping[str, object] | None,
|
||||
timeout: float,
|
||||
) -> httpx.Request:
|
||||
self.sent.append(_SentRequest(method, url, headers, json, timeout))
|
||||
return httpx.Request(method, url, headers=dict(headers), json=json)
|
||||
|
||||
async def send(self, request: httpx.Request, *, stream: bool, follow_redirects: bool) -> httpx.Response:
|
||||
self.follow_redirects.append(follow_redirects)
|
||||
if isinstance(self._respond, httpx.HTTPError):
|
||||
raise self._respond
|
||||
return self._respond
|
||||
|
||||
|
||||
class _CountingStream(httpx.AsyncByteStream):
|
||||
def __init__(self, chunk: bytes, chunks: int) -> None:
|
||||
self.pulled: int = 0 # rebind-ok: test double counts reads
|
||||
self._chunk: Final = chunk
|
||||
self._chunks: Final = chunks
|
||||
|
||||
async def __aiter__(self):
|
||||
for _ in range(self._chunks):
|
||||
self.pulled += 1 # rebind-ok: test double counts reads
|
||||
yield self._chunk
|
||||
|
||||
|
||||
def _config(**overrides: object) -> AgentKillSwitchConfig:
|
||||
return AgentKillSwitchConfig.model_validate({"url": "https://ops.example.com/agents/kill", **overrides})
|
||||
|
||||
|
||||
def test_request_carries_endpoint_method_query_params_headers_and_body() -> None:
|
||||
request: Final = build_kill_switch_request(
|
||||
_config(
|
||||
url="https://ops.example.com/kill?env=prod",
|
||||
method="PUT",
|
||||
query_params={"agent": "billing-bot", "reason": "manual stop"},
|
||||
headers={"X-Trace": "abc"},
|
||||
body={"action": "stop", "hard": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert request.method == "PUT"
|
||||
assert str(httpx.URL(request.url)) == "https://ops.example.com/kill?env=prod&agent=billing-bot&reason=manual+stop"
|
||||
assert dict(request.headers) == {"X-Trace": "abc"}
|
||||
assert request.json_body == {"action": "stop", "hard": True}
|
||||
|
||||
|
||||
def test_request_defaults_to_post_with_no_body_and_untouched_url() -> None:
|
||||
request: Final = build_kill_switch_request(_config())
|
||||
|
||||
assert (request.method, request.url, dict(request.headers), request.json_body) == (
|
||||
"POST",
|
||||
"https://ops.example.com/agents/kill",
|
||||
{},
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("auth", "expected_headers"),
|
||||
[
|
||||
({"type": "bearer", "token": "tok-123"}, {"Authorization": "Bearer tok-123"}),
|
||||
({"type": "api_key", "api_key": "k-456"}, {"x-api-key": "k-456"}),
|
||||
({"type": "api_key", "header_name": "X-Ops-Key", "api_key": "k-456"}, {"X-Ops-Key": "k-456"}),
|
||||
(
|
||||
{"type": "basic", "username": "ops", "password": "pw:1"},
|
||||
{"Authorization": f"Basic {b64encode(b'ops:pw:1').decode()}"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_auth_becomes_the_matching_request_header(auth: Mapping[str, object], expected_headers: dict[str, str]) -> None:
|
||||
request: Final = build_kill_switch_request(_config(auth=auth))
|
||||
|
||||
assert dict(request.headers) == expected_headers
|
||||
|
||||
|
||||
def test_auth_header_wins_over_a_conflicting_custom_header() -> None:
|
||||
request: Final = build_kill_switch_request(
|
||||
_config(headers={"Authorization": "stale", "X-Env": "prod"}, auth={"type": "bearer", "token": "fresh"})
|
||||
)
|
||||
|
||||
assert dict(request.headers) == {"Authorization": "Bearer fresh", "X-Env": "prod"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["ftp://ops.example.com/kill", "/relative/kill", "ops.example.com/kill", ""])
|
||||
def test_config_rejects_non_http_urls(url: str) -> None:
|
||||
with pytest.raises(ValidationError, match="absolute http"):
|
||||
_config(url=url)
|
||||
|
||||
|
||||
def test_config_rejects_unknown_auth_type_and_unknown_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_config(auth={"type": "hmac", "secret": "x"})
|
||||
with pytest.raises(ValidationError):
|
||||
_config(endpoint="https://typo.example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("auth", "secret_field"),
|
||||
[
|
||||
({"type": "bearer", "token": "tok-123"}, "token"),
|
||||
({"type": "api_key", "header_name": "X-K", "api_key": "k-456"}, "api_key"),
|
||||
({"type": "basic", "username": "ops", "password": "pw"}, "password"),
|
||||
],
|
||||
)
|
||||
def test_redact_replaces_only_the_secret_and_restore_puts_it_back(auth: dict[str, str], secret_field: str) -> None:
|
||||
original: Final = _config(auth=auth)
|
||||
|
||||
redacted: Final = redact_kill_switch(original)
|
||||
assert redacted is not None and redacted.auth is not None
|
||||
assert redacted.auth.model_dump() == {**auth, secret_field: REDACTED_BY_LITELM_STRING}
|
||||
assert original.auth is not None and original.auth.model_dump() == auth, "redact must not mutate its input"
|
||||
|
||||
restored: Final = restore_kill_switch(redacted, original)
|
||||
assert restored == original
|
||||
|
||||
|
||||
def test_restore_keeps_a_rotated_secret_and_never_stores_the_marker_itself() -> None:
|
||||
rotated: Final = _config(auth={"type": "bearer", "token": "new-token"})
|
||||
stored: Final = _config(auth={"type": "bearer", "token": "old-token"})
|
||||
assert restore_kill_switch(rotated, stored) == rotated
|
||||
assert restore_kill_switch(None, stored) is None
|
||||
|
||||
marker_only: Final = _config(auth={"type": "bearer", "token": REDACTED_BY_LITELM_STRING})
|
||||
assert restore_kill_switch(marker_only, None) == _config(auth={"type": "bearer", "token": ""})
|
||||
|
||||
|
||||
def test_restore_does_not_borrow_a_secret_from_a_different_auth_type() -> None:
|
||||
incoming: Final = _config(auth={"type": "bearer", "token": REDACTED_BY_LITELM_STRING})
|
||||
stored: Final = _config(auth={"type": "api_key", "api_key": "k-456"})
|
||||
|
||||
assert restore_kill_switch(incoming, stored) == _config(auth={"type": "bearer", "token": ""})
|
||||
|
||||
|
||||
def test_redact_passes_through_configs_without_auth() -> None:
|
||||
assert redact_kill_switch(None) is None
|
||||
plain: Final = _config(headers={"X-Env": "prod"})
|
||||
assert redact_kill_switch(plain) is plain
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_sends_exactly_the_built_request_and_reports_the_2xx_reply_without_the_query() -> None:
|
||||
client: Final = _RecordingClient(httpx.Response(202, text="stopping"))
|
||||
config: Final = _config(
|
||||
method="DELETE",
|
||||
query_params={"force": "1", "token": "qs-secret"},
|
||||
headers={"X-Env": "prod"},
|
||||
body={"agent": "billing-bot"},
|
||||
auth={"type": "bearer", "token": "tok-123"},
|
||||
)
|
||||
|
||||
result: Final = await fire_kill_switch(agent_id="agent-1", config=config, http_client=client, timeout=3.5)
|
||||
|
||||
assert client.sent == [
|
||||
_SentRequest(
|
||||
method="DELETE",
|
||||
url="https://ops.example.com/agents/kill?force=1&token=qs-secret",
|
||||
headers={"X-Env": "prod", "Authorization": "Bearer tok-123"},
|
||||
json={"agent": "billing-bot"},
|
||||
timeout=3.5,
|
||||
)
|
||||
]
|
||||
assert client.follow_redirects == [False], "a redirecting webhook must not be followed to another host"
|
||||
assert result.succeeded is True
|
||||
assert result.model_dump() == {
|
||||
"agent_id": "agent-1",
|
||||
"url": "https://ops.example.com/agents/kill",
|
||||
"method": "DELETE",
|
||||
"status_code": 202,
|
||||
"response_body": "stopping",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_reports_a_non_2xx_reply_as_failure_with_the_body() -> None:
|
||||
client: Final = _RecordingClient(httpx.Response(503, text="x" * 5000))
|
||||
|
||||
result: Final = await fire_kill_switch(agent_id="agent-1", config=_config(), http_client=client)
|
||||
|
||||
assert result.succeeded is False
|
||||
assert result.status_code == 503
|
||||
assert result.response_body == "x" * 2000
|
||||
assert result.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_stops_reading_the_body_at_the_cap_instead_of_buffering_the_whole_reply() -> None:
|
||||
stream: Final = _CountingStream(b"y" * 500, chunks=100)
|
||||
client: Final = _RecordingClient(httpx.Response(200, stream=stream))
|
||||
|
||||
result: Final = await fire_kill_switch(agent_id="agent-1", config=_config(), http_client=client)
|
||||
|
||||
assert result.response_body == "y" * 2000
|
||||
assert stream.pulled == 4, f"read {stream.pulled} of 100 chunks for a 2000 char cap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fire_reports_a_transport_error_by_type_without_raising_or_echoing_the_url() -> None:
|
||||
client: Final = _RecordingClient(httpx.ConnectError("boom https://ops.example.com/agents/kill?token=qs-secret"))
|
||||
|
||||
result: Final = await fire_kill_switch(
|
||||
agent_id="agent-1", config=_config(query_params={"token": "qs-secret"}), http_client=client
|
||||
)
|
||||
|
||||
assert result.succeeded is False
|
||||
assert (result.status_code, result.response_body) == (None, None)
|
||||
assert result.error == "ConnectError"
|
||||
assert "qs-secret" not in result.model_dump_json()
|
||||
|
|
@ -3776,6 +3776,7 @@ AGENT_MANAGEMENT_ROUTES = [
|
|||
"/v1/agents/abc-123",
|
||||
"/v1/agents/make_public",
|
||||
"/v1/agents/abc-123/make_public",
|
||||
"/v1/agents/abc-123/kill_switch",
|
||||
]
|
||||
|
||||
AGENT_INFERENCE_ROUTES = [
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import type { KeyValueFormValue, KillSwitchConfig, KillSwitchFormValue } from "./kill_switch_config";
|
||||
|
||||
export interface AgentSkillFormValue {
|
||||
id?: string;
|
||||
|
|
@ -54,6 +55,8 @@ export type AgentFormFieldValue =
|
|||
| string[]
|
||||
| AgentSkillFormValue[]
|
||||
| StaticHeaderFormValue[]
|
||||
| KeyValueFormValue[]
|
||||
| KillSwitchFormValue
|
||||
| McpServerSelection
|
||||
| Record<string, string[]>
|
||||
| null
|
||||
|
|
@ -82,6 +85,7 @@ export interface AgentFormValues {
|
|||
output_cost_per_token?: string | number;
|
||||
static_headers?: StaticHeaderFormValue[];
|
||||
extra_headers?: string[];
|
||||
kill_switch?: KillSwitchFormValue;
|
||||
tpm_limit?: number | null;
|
||||
rpm_limit?: number | null;
|
||||
session_tpm_limit?: number | null;
|
||||
|
|
@ -123,6 +127,7 @@ export interface AgentRequestPayload {
|
|||
litellm_params?: Record<string, unknown>;
|
||||
object_permission?: Record<string, unknown>;
|
||||
access_group_ids?: string[];
|
||||
kill_switch?: KillSwitchConfig | null;
|
||||
}
|
||||
|
||||
interface AgentFormFieldProps {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import AgentKillSwitchDangerZone from "./AgentKillSwitchDangerZone";
|
||||
import * as networking from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
triggerAgentKillSwitchCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const killSwitch = { url: "https://ops.example.com/kill", method: "DELETE" as const };
|
||||
|
||||
const renderZone = (props: Partial<React.ComponentProps<typeof AgentKillSwitchDangerZone>> = {}) =>
|
||||
render(
|
||||
<AgentKillSwitchDangerZone
|
||||
agentId="agent-1"
|
||||
agentName="support-agent"
|
||||
killSwitch={killSwitch}
|
||||
accessToken="sk-test"
|
||||
isAdmin={true}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
const openDialog = () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fire Kill Switch" }));
|
||||
return screen.getByRole("dialog");
|
||||
};
|
||||
|
||||
const dialogFireButton = () => within(screen.getByRole("dialog")).getByRole("button", { name: "Fire Kill Switch" });
|
||||
|
||||
describe("AgentKillSwitchDangerZone", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(networking.triggerAgentKillSwitchCall).mockReset();
|
||||
vi.mocked(toast.success).mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
});
|
||||
|
||||
it("renders nothing for non-admins", () => {
|
||||
const { container } = renderZone({ isAdmin: false });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows the webhook target and an outage warning inside a Danger Zone region", () => {
|
||||
renderZone();
|
||||
|
||||
const region = screen.getByRole("region", { name: "Danger Zone" });
|
||||
expect(region).toHaveTextContent("DELETE https://ops.example.com/kill");
|
||||
expect(region).toHaveTextContent("can cause an outage");
|
||||
expect(screen.getByRole("button", { name: "Fire Kill Switch" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows an unconfigured notice without a fire button when no kill switch is set", () => {
|
||||
renderZone({ killSwitch: null });
|
||||
|
||||
expect(screen.getByRole("region", { name: "Danger Zone" })).toHaveTextContent("Not configured");
|
||||
expect(screen.queryByRole("button", { name: "Fire Kill Switch" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the confirm button disabled until the exact agent name is typed", () => {
|
||||
renderZone();
|
||||
openDialog();
|
||||
|
||||
expect(dialogFireButton()).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Confirm agent name"), { target: { value: "support-agen" } });
|
||||
expect(dialogFireButton()).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Confirm agent name"), { target: { value: "support-agent" } });
|
||||
expect(dialogFireButton()).toBeEnabled();
|
||||
expect(networking.triggerAgentKillSwitchCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires the webhook after typed confirmation, closes the dialog and shows the sanitized result", async () => {
|
||||
const firedResult = {
|
||||
agent_id: "agent-1",
|
||||
url: killSwitch.url,
|
||||
method: "DELETE" as const,
|
||||
status_code: 202,
|
||||
response_body: '{"stopped": true}',
|
||||
};
|
||||
vi.mocked(networking.triggerAgentKillSwitchCall).mockResolvedValue(firedResult);
|
||||
renderZone();
|
||||
openDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Confirm agent name"), { target: { value: "support-agent" } });
|
||||
fireEvent.click(dialogFireButton());
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent('Last result: HTTP 202 {"stopped": true}');
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(networking.triggerAgentKillSwitchCall).toHaveBeenCalledWith("sk-test", "agent-1");
|
||||
expect(toast.success).toHaveBeenCalledWith("Kill switch fired (HTTP 202)");
|
||||
});
|
||||
|
||||
it("does not call the webhook when the dialog is cancelled", () => {
|
||||
renderZone();
|
||||
openDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Confirm agent name"), { target: { value: "support-agent" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(networking.triggerAgentKillSwitchCall).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a failed webhook as an error toast and keeps the dialog open", async () => {
|
||||
vi.mocked(networking.triggerAgentKillSwitchCall).mockRejectedValue(new Error("Kill switch webhook returned 500"));
|
||||
renderZone();
|
||||
openDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Confirm agent name"), { target: { value: "support-agent" } });
|
||||
fireEvent.click(dialogFireButton());
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Kill switch webhook returned 500"));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { AgentKillSwitchResult, triggerAgentKillSwitchCall } from "@/components/networking";
|
||||
import { KillSwitchConfig } from "./kill_switch_config";
|
||||
|
||||
interface AgentKillSwitchDangerZoneProps {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
killSwitch: KillSwitchConfig | null | undefined;
|
||||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
killSwitch,
|
||||
accessToken,
|
||||
isAdmin,
|
||||
}) => {
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [confirmationInput, setConfirmationInput] = useState("");
|
||||
const [isFiring, setIsFiring] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<AgentKillSwitchResult | null>(null);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const openConfirm = () => {
|
||||
setConfirmationInput("");
|
||||
setIsConfirmOpen(true);
|
||||
};
|
||||
|
||||
const fire = async () => {
|
||||
if (!accessToken) return;
|
||||
setIsFiring(true);
|
||||
setLastResult(null);
|
||||
try {
|
||||
const result = await triggerAgentKillSwitchCall(accessToken, agentId);
|
||||
setLastResult(result);
|
||||
setIsConfirmOpen(false);
|
||||
toast.success(`Kill switch fired (HTTP ${result.status_code})`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to fire kill switch");
|
||||
} finally {
|
||||
setIsFiring(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="agent-danger-zone-heading" className="mt-6">
|
||||
<h3 id="agent-danger-zone-heading" className="text-lg font-medium text-destructive">
|
||||
Danger Zone
|
||||
</h3>
|
||||
<div className="mt-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">Kill switch</p>
|
||||
{killSwitch ? (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
Calls the configured webhook to stop this agent's upstream runtime. This can cause an outage for
|
||||
everyone using the agent and cannot be undone from LiteLLM
|
||||
</p>
|
||||
<p className="font-mono break-all text-foreground">
|
||||
{killSwitch.method ?? "POST"} {killSwitch.url}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Not configured. Add a kill switch webhook under Settings to enable this action
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{killSwitch && (
|
||||
<Button type="button" variant="destructive" onClick={openConfirm} disabled={isFiring} aria-busy={isFiring}>
|
||||
{isFiring && <UiLoadingSpinner className="size-4" />}
|
||||
Fire Kill Switch
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{lastResult && (
|
||||
<p className="mt-3 text-sm text-muted-foreground" role="status">
|
||||
Last result: HTTP {lastResult.status_code}
|
||||
{lastResult.response_body ? ` ${lastResult.response_body}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isConfirmOpen} onOpenChange={(open) => !open && !isFiring && setIsConfirmOpen(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fire kill switch for {agentName}?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Alert variant="error">
|
||||
<CircleAlert />
|
||||
<AlertTitle>This can cause an outage</AlertTitle>
|
||||
<AlertDescription>
|
||||
LiteLLM will call {killSwitch?.method ?? "POST"} {killSwitch?.url} immediately. Whatever that webhook
|
||||
does to the agent is outside LiteLLM's control and cannot be reverted here
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div>
|
||||
<p className="mb-2 text-base font-medium text-foreground">
|
||||
Type <span className="font-semibold text-destructive">{agentName}</span> to confirm:
|
||||
</p>
|
||||
<InputGroup className="rounded-md">
|
||||
<InputGroupAddon>
|
||||
<CircleAlert className="size-3.5 text-destructive" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label="Confirm agent name"
|
||||
value={confirmationInput}
|
||||
onChange={(e) => setConfirmationInput(e.target.value)}
|
||||
placeholder={agentName}
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConfirmOpen(false)} disabled={isFiring}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={fire}
|
||||
disabled={confirmationInput !== agentName || isFiring}
|
||||
aria-busy={isFiring}
|
||||
>
|
||||
{isFiring && <UiLoadingSpinner className="size-4" />}
|
||||
{isFiring ? "Firing..." : "Fire Kill Switch"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentKillSwitchDangerZone;
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import React from "react";
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Field, FieldTitle } from "@/components/ui/field";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { AgentFormField, AgentFormValues, labelWithHint } from "./AgentFormKit";
|
||||
import { KILL_SWITCH_AUTH_TYPES, KILL_SWITCH_METHODS, validateKillSwitchBody } from "./kill_switch_config";
|
||||
|
||||
const KeyValueFieldArray = ({
|
||||
name,
|
||||
addLabel,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
}: {
|
||||
name: "kill_switch.headers" | "kill_switch.query_params";
|
||||
addLabel: string;
|
||||
keyPlaceholder: string;
|
||||
valuePlaceholder: string;
|
||||
}) => {
|
||||
const { control } = useFormContext<AgentFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="flex items-start gap-2">
|
||||
<AgentFormField name={`${name}.${index}.key`} rules={{ required: "Name required" }}>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
className="w-55"
|
||||
placeholder={keyPlaceholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField name={`${name}.${index}.value`}>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
className="w-65"
|
||||
placeholder={valuePlaceholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove ${addLabel.replace(/^Add /, "").toLowerCase()}`}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
|
||||
<Plus />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TextField = ({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
required,
|
||||
secret,
|
||||
}: {
|
||||
name: `kill_switch.${string}`;
|
||||
label: React.ReactNode;
|
||||
placeholder?: string;
|
||||
required?: string;
|
||||
secret?: boolean;
|
||||
}) => (
|
||||
<AgentFormField name={name} label={label} rules={required ? { required } : undefined}>
|
||||
{({ value, onChange, ref, ...control }) =>
|
||||
secret ? (
|
||||
<PasswordInput
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AgentFormField>
|
||||
);
|
||||
|
||||
const KillSwitchAuthFields = () => {
|
||||
const { control } = useFormContext<AgentFormValues>();
|
||||
const authType = useWatch({ control, name: "kill_switch.auth_type" });
|
||||
|
||||
switch (authType) {
|
||||
case "bearer":
|
||||
return <TextField name="kill_switch.auth_token" label="Bearer token" required="Token required" secret />;
|
||||
case "api_key":
|
||||
return (
|
||||
<>
|
||||
<TextField name="kill_switch.auth_header_name" label="Header name" placeholder="X-API-Key" />
|
||||
<TextField name="kill_switch.auth_api_key" label="API key" required="API key required" secret />
|
||||
</>
|
||||
);
|
||||
case "basic":
|
||||
return (
|
||||
<>
|
||||
<TextField name="kill_switch.auth_username" label="Username" required="Username required" />
|
||||
<TextField name="kill_switch.auth_password" label="Password" required="Password required" secret />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const KillSwitchFormFields = () => (
|
||||
<>
|
||||
<TextField
|
||||
name="kill_switch.url"
|
||||
label={labelWithHint(
|
||||
"Webhook URL",
|
||||
"Absolute http(s) URL LiteLLM calls when the kill switch is triggered. Leave empty to remove the kill switch.",
|
||||
)}
|
||||
placeholder="https://example.com/hooks/kill-agent"
|
||||
/>
|
||||
|
||||
<AgentFormField name="kill_switch.method" label="Method">
|
||||
{({ value, onChange, ref: _ref, ...control }) => (
|
||||
<Select value={typeof value === "string" ? value : "POST"} onValueChange={onChange}>
|
||||
<SelectTrigger {...control} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KILL_SWITCH_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{method}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<Field>
|
||||
<FieldTitle>Headers</FieldTitle>
|
||||
<KeyValueFieldArray
|
||||
name="kill_switch.headers"
|
||||
addLabel="Add Header"
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle>Query Parameters</FieldTitle>
|
||||
<KeyValueFieldArray
|
||||
name="kill_switch.query_params"
|
||||
addLabel="Add Query Parameter"
|
||||
keyPlaceholder="Parameter name"
|
||||
valuePlaceholder="Parameter value"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AgentFormField
|
||||
name="kill_switch.body"
|
||||
label={labelWithHint("JSON Body", "Optional JSON object sent as the request body")}
|
||||
rules={{ validate: (value) => validateKillSwitchBody(typeof value === "string" ? value : "") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
ref={ref}
|
||||
rows={4}
|
||||
placeholder='{"reason": "manual kill switch"}'
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<AgentFormField name="kill_switch.auth_type" label="Authentication">
|
||||
{({ value, onChange, ref: _ref, ...control }) => (
|
||||
<Select value={typeof value === "string" ? value : "none"} onValueChange={onChange}>
|
||||
<SelectTrigger {...control} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KILL_SWITCH_AUTH_TYPES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<KillSwitchAuthFields />
|
||||
</>
|
||||
);
|
||||
|
||||
export default KillSwitchFormFields;
|
||||
|
|
@ -3,6 +3,14 @@
|
|||
* Used across create, view, and update operations
|
||||
*/
|
||||
|
||||
import {
|
||||
EMPTY_KILL_SWITCH_FORM,
|
||||
buildKillSwitchFromForm,
|
||||
parseKillSwitchForForm,
|
||||
type KillSwitchConfig,
|
||||
type KillSwitchFormValue,
|
||||
} from "./kill_switch_config";
|
||||
|
||||
export interface FieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
|
|
@ -236,6 +244,7 @@ export const getDefaultFormValues = () => {
|
|||
const defaults: any = {
|
||||
defaultInputModes: ["text"],
|
||||
defaultOutputModes: ["text"],
|
||||
kill_switch: { ...EMPTY_KILL_SWITCH_FORM },
|
||||
};
|
||||
|
||||
Object.values(AGENT_FORM_CONFIG).forEach((section) => {
|
||||
|
|
@ -310,9 +319,22 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
|
|||
agentData.extra_headers = values.extra_headers;
|
||||
}
|
||||
|
||||
applyKillSwitchToPayload(agentData, values.kill_switch, existingAgent);
|
||||
|
||||
return agentData;
|
||||
};
|
||||
|
||||
export const applyKillSwitchToPayload = (
|
||||
agentData: { kill_switch?: KillSwitchConfig | null },
|
||||
form: KillSwitchFormValue | undefined,
|
||||
existingAgent?: { kill_switch?: KillSwitchConfig | null },
|
||||
) => {
|
||||
const killSwitch = buildKillSwitchFromForm(form);
|
||||
if (killSwitch !== undefined && (killSwitch !== null || existingAgent?.kill_switch)) {
|
||||
agentData.kill_switch = killSwitch;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseAccessGroupIdsForForm = (agent: { access_group_ids?: string[] | null }) => ({
|
||||
access_group_ids: agent.access_group_ids ?? [],
|
||||
});
|
||||
|
|
@ -380,6 +402,7 @@ export const parseAgentForForm = (agent: any) => {
|
|||
: [],
|
||||
// extra_headers: already an array of strings
|
||||
extra_headers: agent.extra_headers ?? [],
|
||||
kill_switch: parseKillSwitchForForm(agent.kill_switch),
|
||||
...parseMcpPermissionsForForm(agent),
|
||||
...parseAccessGroupIdsForForm(agent),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { Field, FieldGroup, FieldTitle } from "@/components/ui/field";
|
||||
import { AGENT_FORM_CONFIG, SKILL_FIELD_CONFIG } from "./agent_config";
|
||||
import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields";
|
||||
import KillSwitchFormFields from "./KillSwitchFormFields";
|
||||
import { KILL_SWITCH_PANEL_KEY } from "./kill_switch_config";
|
||||
import {
|
||||
AgentFormField,
|
||||
AgentFormPanel,
|
||||
|
|
@ -30,6 +32,7 @@ export const A2A_PANEL_FIELD_NAMES: Readonly<Record<string, readonly string[]>>
|
|||
[AGENT_FORM_CONFIG.cost.key]: COST_FIELD_NAMES,
|
||||
[AGENT_FORM_CONFIG.litellm.key]: namesOf(AGENT_FORM_CONFIG.litellm.fields),
|
||||
[AUTH_HEADERS_PANEL_KEY]: ["static_headers", "extra_headers"],
|
||||
[KILL_SWITCH_PANEL_KEY]: ["kill_switch"],
|
||||
};
|
||||
|
||||
export const unmountedA2AFieldNames = (mountedPanels: readonly string[]): readonly string[] =>
|
||||
|
|
@ -394,6 +397,12 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
</AgentFormField>
|
||||
</AgentFormPanel>
|
||||
)}
|
||||
|
||||
{shouldShow(KILL_SWITCH_PANEL_KEY) && (
|
||||
<AgentFormPanel panelKey={KILL_SWITCH_PANEL_KEY} title="Kill Switch" panels={panels}>
|
||||
<KillSwitchFormFields />
|
||||
</AgentFormPanel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ vi.mock("@/components/networking", () => ({
|
|||
getAgentInfo: vi.fn(),
|
||||
getAgentCreateMetadata: vi.fn(),
|
||||
patchAgentCall: vi.fn(),
|
||||
triggerAgentKillSwitchCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
|
||||
|
|
@ -75,9 +76,11 @@ const agent = {
|
|||
|
||||
describe("AgentInfoView settings", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(networking.getAgentInfo).mockReset().mockResolvedValue(agent);
|
||||
vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(networking.patchAgentCall).mockReset().mockResolvedValue({});
|
||||
vi.mocked(networking.triggerAgentKillSwitchCall).mockReset();
|
||||
});
|
||||
|
||||
it("submits the edited agent when Save Changes is pressed", async () => {
|
||||
|
|
@ -158,4 +161,29 @@ describe("AgentInfoView settings", () => {
|
|||
expect(await screen.findByText("Access Groups")).toBeInTheDocument();
|
||||
expect(screen.getByText("None")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the kill switch Danger Zone for admins with the configured webhook", async () => {
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue({
|
||||
...agent,
|
||||
kill_switch: { url: "https://ops.example.com/kill", method: "DELETE" },
|
||||
});
|
||||
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
|
||||
|
||||
const dangerZone = await screen.findByRole("region", { name: "Danger Zone" });
|
||||
expect(dangerZone).toHaveTextContent("DELETE https://ops.example.com/kill");
|
||||
expect(screen.getByRole("button", { name: "Fire Kill Switch" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Kill Switch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Danger Zone from non-admins", async () => {
|
||||
vi.mocked(networking.getAgentInfo).mockResolvedValue({
|
||||
...agent,
|
||||
kill_switch: { url: "https://ops.example.com/kill", method: "POST" },
|
||||
});
|
||||
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={false} />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "support-agent" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Danger Zone" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Fire Kill Switch" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import KeyInfoView from "@/components/templates/key_info_view";
|
|||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions";
|
||||
import AgentVirtualKeys from "./agent_virtual_keys";
|
||||
import AgentKillSwitchDangerZone from "./AgentKillSwitchDangerZone";
|
||||
import AgentFormFields, { unmountedA2AFieldNames } from "./agent_form_fields";
|
||||
import DynamicAgentFormFields, { buildDynamicAgentData, unmountedDynamicFieldNames } from "./dynamic_agent_form_fields";
|
||||
import {
|
||||
|
|
@ -228,7 +229,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
);
|
||||
|
||||
const built: AgentRequestPayload = usesDynamicFields
|
||||
? { ...buildDynamicAgentData(values, selectedAgentTypeInfo), agent_name: values.agent_name }
|
||||
? { ...buildDynamicAgentData(values, selectedAgentTypeInfo, agent), agent_name: values.agent_name }
|
||||
: buildAgentDataFromForm(values, agent);
|
||||
|
||||
const updateData = appliedDiscoveredSelection
|
||||
|
|
@ -459,6 +460,14 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
</DetailList>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentKillSwitchDangerZone
|
||||
agentId={agent.agent_id}
|
||||
agentName={agent.agent_name}
|
||||
killSwitch={agent.kill_switch}
|
||||
accessToken={accessToken}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Panel (only for admins) */}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,32 @@ describe("parseDynamicAgentForForm", () => {
|
|||
|
||||
expect(values.agent_runtime_arn).toBe(FULL_RUNTIME_ARN);
|
||||
});
|
||||
|
||||
it("loads the stored kill switch into the edit form for non-A2A agents", () => {
|
||||
const agent = {
|
||||
agent_id: "agent-1",
|
||||
agent_name: "bedrock-agent",
|
||||
agent_card_params: { description: "" },
|
||||
litellm_params: { model: `bedrock/agentcore/${FULL_RUNTIME_ARN}` },
|
||||
kill_switch: {
|
||||
url: "https://ops.example.com/kill",
|
||||
method: "DELETE",
|
||||
headers: { "X-Env": "prod" },
|
||||
auth: { type: "bearer", token: "REDACTED_BY_LITELM" },
|
||||
},
|
||||
} as unknown as Agent;
|
||||
|
||||
const values = parseDynamicAgentForForm(agent, bedrockAgentcoreInfo);
|
||||
|
||||
const expectedForm = {
|
||||
url: "https://ops.example.com/kill",
|
||||
method: "DELETE",
|
||||
headers: [{ key: "X-Env", value: "prod" }],
|
||||
auth_type: "bearer",
|
||||
auth_token: "REDACTED_BY_LITELM",
|
||||
};
|
||||
expect(values.kill_switch).toMatchObject(expectedForm);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAgentType", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Agent } from "@/components/agents/types";
|
||||
import { AgentCreateInfo } from "@/components/networking";
|
||||
import { parseKillSwitchForForm } from "./kill_switch_config";
|
||||
|
||||
/**
|
||||
* Detects the agent type from an agent's litellm_params.
|
||||
|
|
@ -77,6 +78,7 @@ export const parseDynamicAgentForForm = (agent: Agent, agentTypeInfo: AgentCreat
|
|||
values.cost_per_query = agent.litellm_params?.cost_per_query;
|
||||
values.input_cost_per_token = agent.litellm_params?.input_cost_per_token;
|
||||
values.output_cost_per_token = agent.litellm_params?.output_cost_per_token;
|
||||
values.kill_switch = parseKillSwitchForForm(agent.kill_switch);
|
||||
|
||||
return values;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentCreateInfo } from "@/components/networking";
|
||||
import type { AgentFormValues } from "./AgentFormKit";
|
||||
import { AGENT_FORM_CONFIG } from "./agent_config";
|
||||
import { buildDynamicAgentData, unmountedDynamicFieldNames } from "./dynamic_agent_form_fields";
|
||||
import {
|
||||
EMPTY_KILL_SWITCH_FORM,
|
||||
KILL_SWITCH_PANEL_KEY,
|
||||
type KillSwitchConfig,
|
||||
type KillSwitchFormValue,
|
||||
} from "./kill_switch_config";
|
||||
|
||||
const langgraphInfo: AgentCreateInfo = {
|
||||
agent_type: "langgraph",
|
||||
agent_type_display_name: "LangGraph",
|
||||
model_template: "langgraph/{assistant_id}",
|
||||
credential_fields: [{ key: "assistant_id", label: "Assistant ID", required: true, include_in_litellm_params: false }],
|
||||
};
|
||||
|
||||
const killSwitchForm: KillSwitchFormValue = {
|
||||
...EMPTY_KILL_SWITCH_FORM,
|
||||
url: "https://ops.example.com/kill",
|
||||
method: "DELETE",
|
||||
headers: [{ key: "X-Env", value: "prod" }],
|
||||
auth_type: "bearer",
|
||||
auth_token: "tok",
|
||||
};
|
||||
|
||||
const baseValues: AgentFormValues = { agent_name: "lg-agent", assistant_id: "asst_1" };
|
||||
|
||||
describe("buildDynamicAgentData kill switch", () => {
|
||||
it("serializes the kill switch section into the payload", () => {
|
||||
const payload = buildDynamicAgentData({ ...baseValues, kill_switch: killSwitchForm }, langgraphInfo);
|
||||
|
||||
const expected: KillSwitchConfig = {
|
||||
url: "https://ops.example.com/kill",
|
||||
method: "DELETE",
|
||||
headers: { "X-Env": "prod" },
|
||||
query_params: {},
|
||||
body: null,
|
||||
auth: { type: "bearer", token: "tok" },
|
||||
};
|
||||
expect(payload.kill_switch).toEqual(expected);
|
||||
expect(payload.litellm_params).toMatchObject({ model: "langgraph/asst_1" });
|
||||
});
|
||||
|
||||
it("leaves kill_switch off the payload when the section was never mounted", () => {
|
||||
expect("kill_switch" in buildDynamicAgentData(baseValues, langgraphInfo)).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a stored kill switch when the URL is blanked, but not on a fresh create", () => {
|
||||
const blanked: AgentFormValues = { ...baseValues, kill_switch: { ...EMPTY_KILL_SWITCH_FORM } };
|
||||
|
||||
expect("kill_switch" in buildDynamicAgentData(blanked, langgraphInfo)).toBe(false);
|
||||
const stored = { kill_switch: { url: "https://old.example", method: "POST" as const } };
|
||||
expect(buildDynamicAgentData(blanked, langgraphInfo, stored).kill_switch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unmountedDynamicFieldNames", () => {
|
||||
it("drops kill_switch from the submit only while its panel is unmounted", () => {
|
||||
expect(unmountedDynamicFieldNames([AGENT_FORM_CONFIG.cost.key])).toEqual(["kill_switch"]);
|
||||
expect(unmountedDynamicFieldNames([AGENT_FORM_CONFIG.cost.key, KILL_SWITCH_PANEL_KEY])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,8 +5,10 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { AGENT_FORM_CONFIG } from "./agent_config";
|
||||
import { AGENT_FORM_CONFIG, applyKillSwitchToPayload } from "./agent_config";
|
||||
import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields";
|
||||
import KillSwitchFormFields from "./KillSwitchFormFields";
|
||||
import { KILL_SWITCH_PANEL_KEY, type KillSwitchConfig } from "./kill_switch_config";
|
||||
import {
|
||||
AgentFormField,
|
||||
AgentFormPanel,
|
||||
|
|
@ -21,8 +23,10 @@ interface DynamicAgentFormFieldsProps {
|
|||
panels: CollapsiblePanelsState;
|
||||
}
|
||||
|
||||
export const unmountedDynamicFieldNames = (mountedPanels: readonly string[]): readonly string[] =>
|
||||
mountedPanels.includes(AGENT_FORM_CONFIG.cost.key) ? [] : COST_FIELD_NAMES;
|
||||
export const unmountedDynamicFieldNames = (mountedPanels: readonly string[]): readonly string[] => [
|
||||
...(mountedPanels.includes(AGENT_FORM_CONFIG.cost.key) ? [] : COST_FIELD_NAMES),
|
||||
...(mountedPanels.includes(KILL_SWITCH_PANEL_KEY) ? [] : ["kill_switch"]),
|
||||
];
|
||||
|
||||
// A field's validation_pattern is server-supplied metadata; if it's ever not a valid regex, skip
|
||||
// validation rather than throwing during render and taking the whole form down with it.
|
||||
|
|
@ -143,11 +147,18 @@ const DynamicAgentFormFields: React.FC<DynamicAgentFormFieldsProps> = ({ agentTy
|
|||
<AgentFormPanel panelKey={AGENT_FORM_CONFIG.cost.key} title={AGENT_FORM_CONFIG.cost.title} panels={panels}>
|
||||
<CostConfigFields />
|
||||
</AgentFormPanel>
|
||||
<AgentFormPanel panelKey={KILL_SWITCH_PANEL_KEY} title="Kill Switch" panels={panels}>
|
||||
<KillSwitchFormFields />
|
||||
</AgentFormPanel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export const buildDynamicAgentData = (values: AgentFormValues, agentTypeInfo: AgentCreateInfo): AgentRequestPayload => {
|
||||
export const buildDynamicAgentData = (
|
||||
values: AgentFormValues,
|
||||
agentTypeInfo: AgentCreateInfo,
|
||||
existingAgent?: { kill_switch?: KillSwitchConfig | null },
|
||||
): AgentRequestPayload => {
|
||||
const litellmParams: Record<string, unknown> = {
|
||||
...(agentTypeInfo.litellm_params_template || {}),
|
||||
};
|
||||
|
|
@ -207,6 +218,8 @@ export const buildDynamicAgentData = (values: AgentFormValues, agentTypeInfo: Ag
|
|||
if (values.session_tpm_limit != null) agentData.session_tpm_limit = values.session_tpm_limit;
|
||||
if (values.session_rpm_limit != null) agentData.session_rpm_limit = values.session_rpm_limit;
|
||||
|
||||
applyKillSwitchToPayload(agentData, values.kill_switch, existingAgent);
|
||||
|
||||
return agentData;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EMPTY_KILL_SWITCH_FORM,
|
||||
buildKillSwitchFromForm,
|
||||
parseKillSwitchForForm,
|
||||
validateKillSwitchBody,
|
||||
type KillSwitchConfig,
|
||||
type KillSwitchFormValue,
|
||||
} from "./kill_switch_config";
|
||||
|
||||
const fullConfig: KillSwitchConfig = {
|
||||
url: "https://ops.example.com/kill?env=prod",
|
||||
method: "DELETE",
|
||||
headers: { "X-Env": "prod" },
|
||||
query_params: { agent: "billing-bot" },
|
||||
body: { reason: "manual stop", force: true },
|
||||
auth: { type: "api_key", header_name: "X-Ops-Key", api_key: "k-456" },
|
||||
};
|
||||
|
||||
describe("buildKillSwitchFromForm", () => {
|
||||
it("returns undefined when the form never touched the kill switch", () => {
|
||||
expect(buildKillSwitchFromForm(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null when the URL is blank so the backend clears the config", () => {
|
||||
const blankUrlForm: KillSwitchFormValue = {
|
||||
...EMPTY_KILL_SWITCH_FORM,
|
||||
url: " ",
|
||||
auth_type: "bearer",
|
||||
auth_token: "t",
|
||||
};
|
||||
expect(buildKillSwitchFromForm(blankUrlForm)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the full config, dropping rows without a key and parsing the JSON body", () => {
|
||||
const fullForm: KillSwitchFormValue = {
|
||||
url: " https://ops.example.com/kill?env=prod ",
|
||||
method: "DELETE",
|
||||
headers: [
|
||||
{ key: "X-Env", value: "prod" },
|
||||
{ key: " ", value: "ignored" },
|
||||
],
|
||||
query_params: [{ key: "agent", value: "billing-bot" }],
|
||||
body: '{"reason": "manual stop", "force": true}',
|
||||
auth_type: "api_key",
|
||||
auth_header_name: "X-Ops-Key",
|
||||
auth_api_key: "k-456",
|
||||
};
|
||||
expect(buildKillSwitchFromForm(fullForm)).toEqual(fullConfig);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ auth_type: "none" as const, auth_token: "leftover" }, null],
|
||||
[
|
||||
{ auth_type: "bearer" as const, auth_token: "tok-123" },
|
||||
{ type: "bearer", token: "tok-123" },
|
||||
],
|
||||
[
|
||||
{ auth_type: "api_key" as const, auth_api_key: "k" },
|
||||
{ type: "api_key", header_name: "X-API-Key", api_key: "k" },
|
||||
],
|
||||
[
|
||||
{ auth_type: "basic" as const, auth_username: "ops", auth_password: "pw" },
|
||||
{ type: "basic", username: "ops", password: "pw" },
|
||||
],
|
||||
])("maps auth form fields %j to %j", (authFields, expectedAuth) => {
|
||||
const authForm: KillSwitchFormValue = { ...EMPTY_KILL_SWITCH_FORM, url: "https://x.example", ...authFields };
|
||||
expect(buildKillSwitchFromForm(authForm)?.auth).toEqual(expectedAuth);
|
||||
});
|
||||
|
||||
it("sends an empty body as null and defaults the method to POST", () => {
|
||||
const expected: KillSwitchConfig = {
|
||||
url: "https://x.example",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
query_params: {},
|
||||
body: null,
|
||||
auth: null,
|
||||
};
|
||||
expect(buildKillSwitchFromForm({ url: "https://x.example", body: " " })).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateKillSwitchBody", () => {
|
||||
it.each(["", " ", undefined, '{"a": 1}'])("accepts %j", (text) => {
|
||||
expect(validateKillSwitchBody(text)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["[1, 2]", '"text"', "42", "null"])("rejects non-object JSON %s", (text) => {
|
||||
expect(validateKillSwitchBody(text)).toBe("Body must be a JSON object");
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with the parser message", () => {
|
||||
expect(validateKillSwitchBody("{not json")).toMatch(/JSON/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKillSwitchForForm", () => {
|
||||
it("returns the empty form for a missing config", () => {
|
||||
expect(parseKillSwitchForForm(null)).toEqual(EMPTY_KILL_SWITCH_FORM);
|
||||
expect(parseKillSwitchForForm(undefined)).toEqual(EMPTY_KILL_SWITCH_FORM);
|
||||
});
|
||||
|
||||
it("round-trips a full config through the form representation", () => {
|
||||
expect(buildKillSwitchFromForm(parseKillSwitchForForm(fullConfig))).toEqual(fullConfig);
|
||||
});
|
||||
|
||||
it("keeps the redacted secret marker in the auth field so the backend restores it", () => {
|
||||
const parsed = parseKillSwitchForForm({
|
||||
url: "https://x.example",
|
||||
method: "POST",
|
||||
auth: { type: "bearer", token: "redacted-marker" },
|
||||
});
|
||||
expect(parsed.auth_type).toBe("bearer");
|
||||
expect(parsed.auth_token).toBe("redacted-marker");
|
||||
expect(parsed.auth_password).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type KillSwitchConfig = components["schemas"]["AgentKillSwitchConfig"];
|
||||
export type KillSwitchAuth = NonNullable<KillSwitchConfig["auth"]>;
|
||||
export type KillSwitchMethod = NonNullable<KillSwitchConfig["method"]>;
|
||||
export type KillSwitchAuthType = KillSwitchAuth["type"] | "none";
|
||||
|
||||
export const KILL_SWITCH_METHODS: readonly KillSwitchMethod[] = ["POST", "PUT", "PATCH", "DELETE", "GET"];
|
||||
export const KILL_SWITCH_AUTH_TYPES: readonly { value: KillSwitchAuthType; label: string }[] = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "bearer", label: "Bearer token" },
|
||||
{ value: "api_key", label: "API key header" },
|
||||
{ value: "basic", label: "Basic auth" },
|
||||
];
|
||||
|
||||
export interface KeyValueFormValue {
|
||||
key?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface KillSwitchFormValue {
|
||||
url?: string;
|
||||
method?: KillSwitchMethod;
|
||||
headers?: KeyValueFormValue[];
|
||||
query_params?: KeyValueFormValue[];
|
||||
body?: string;
|
||||
auth_type?: KillSwitchAuthType;
|
||||
auth_token?: string;
|
||||
auth_header_name?: string;
|
||||
auth_api_key?: string;
|
||||
auth_username?: string;
|
||||
auth_password?: string;
|
||||
}
|
||||
|
||||
export const KILL_SWITCH_PANEL_KEY = "kill_switch";
|
||||
|
||||
export const EMPTY_KILL_SWITCH_FORM: Readonly<KillSwitchFormValue> = {
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [],
|
||||
query_params: [],
|
||||
body: "",
|
||||
auth_type: "none",
|
||||
};
|
||||
|
||||
const pairsToRecord = (pairs: readonly KeyValueFormValue[] | undefined): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
(pairs ?? []).map((pair) => [pair.key?.trim() ?? "", pair.value ?? ""] as const).filter(([key]) => key.length > 0),
|
||||
);
|
||||
|
||||
const recordToPairs = (record: Record<string, string> | undefined | null): KeyValueFormValue[] =>
|
||||
Object.entries(record ?? {}).map(([key, value]) => ({ key, value }));
|
||||
|
||||
export const parseKillSwitchBody = (text: string | undefined): Record<string, unknown> | null => {
|
||||
const trimmed = text?.trim() ?? "";
|
||||
if (trimmed.length === 0) return null;
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Body must be a JSON object");
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const validateKillSwitchBody = (text: string | undefined): true | string => {
|
||||
try {
|
||||
parseKillSwitchBody(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Body must be valid JSON";
|
||||
}
|
||||
};
|
||||
|
||||
const buildAuth = (form: KillSwitchFormValue): KillSwitchAuth | null => {
|
||||
switch (form.auth_type) {
|
||||
case "bearer":
|
||||
return { type: "bearer", token: form.auth_token ?? "" };
|
||||
case "api_key":
|
||||
return {
|
||||
type: "api_key",
|
||||
header_name: form.auth_header_name?.trim() || "X-API-Key",
|
||||
api_key: form.auth_api_key ?? "",
|
||||
};
|
||||
case "basic":
|
||||
return { type: "basic", username: form.auth_username ?? "", password: form.auth_password ?? "" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* `undefined` means the form never touched the kill switch (leave it as is),
|
||||
* `null` means the user cleared the URL (remove it), otherwise the config to save.
|
||||
*/
|
||||
export const buildKillSwitchFromForm = (form: KillSwitchFormValue | undefined): KillSwitchConfig | null | undefined => {
|
||||
if (form === undefined) return undefined;
|
||||
const url = form.url?.trim() ?? "";
|
||||
if (url.length === 0) return null;
|
||||
return {
|
||||
url,
|
||||
method: form.method ?? "POST",
|
||||
headers: pairsToRecord(form.headers),
|
||||
query_params: pairsToRecord(form.query_params),
|
||||
body: parseKillSwitchBody(form.body),
|
||||
auth: buildAuth(form),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseKillSwitchForForm = (config: KillSwitchConfig | null | undefined): KillSwitchFormValue => {
|
||||
if (!config) return { ...EMPTY_KILL_SWITCH_FORM };
|
||||
const auth = config.auth ?? null;
|
||||
return {
|
||||
url: config.url,
|
||||
method: config.method ?? "POST",
|
||||
headers: recordToPairs(config.headers),
|
||||
query_params: recordToPairs(config.query_params),
|
||||
body: config.body ? JSON.stringify(config.body, null, 2) : "",
|
||||
auth_type: auth?.type ?? "none",
|
||||
auth_token: auth?.type === "bearer" ? auth.token : "",
|
||||
auth_header_name: auth?.type === "api_key" ? auth.header_name : "",
|
||||
auth_api_key: auth?.type === "api_key" ? auth.api_key : "",
|
||||
auth_username: auth?.type === "basic" ? auth.username : "",
|
||||
auth_password: auth?.type === "basic" ? auth.password : "",
|
||||
};
|
||||
};
|
||||
|
|
@ -7,6 +7,8 @@ export interface AgentAttachedKey {
|
|||
}
|
||||
|
||||
export type AgentObjectPermission = components["schemas"]["AgentObjectPermission"];
|
||||
export type AgentKillSwitchConfig = components["schemas"]["AgentKillSwitchConfig"];
|
||||
export type AgentKillSwitchResult = components["schemas"]["AgentKillSwitchResult"];
|
||||
|
||||
export interface Agent {
|
||||
agent_id: string;
|
||||
|
|
@ -22,6 +24,7 @@ export interface Agent {
|
|||
};
|
||||
object_permission?: AgentObjectPermission;
|
||||
access_group_ids?: string[] | null;
|
||||
kill_switch?: AgentKillSwitchConfig | null;
|
||||
keys?: AgentAttachedKey[] | null;
|
||||
spend?: number;
|
||||
tpm_limit?: number | null;
|
||||
|
|
|
|||
|
|
@ -6273,6 +6273,16 @@ export const getAgentInfo = async (accessToken: string, agentId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
export type AgentKillSwitchResult = components["schemas"]["AgentKillSwitchResult"];
|
||||
|
||||
export const triggerAgentKillSwitchCall = async (
|
||||
accessToken: string,
|
||||
agentId: string,
|
||||
): Promise<AgentKillSwitchResult> =>
|
||||
await apiClient.post<AgentKillSwitchResult>(`/v1/agents/${encodeURIComponent(agentId)}/kill_switch`, {
|
||||
accessToken,
|
||||
});
|
||||
|
||||
export const getGuardrailInfo = async (accessToken: string, guardrailId: string) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/${guardrailId}/info` : `/guardrails/${guardrailId}/info`;
|
||||
|
|
@ -6312,6 +6322,7 @@ export const patchAgentCall = async (
|
|||
session_tpm_limit?: number | null;
|
||||
session_rpm_limit?: number | null;
|
||||
access_group_ids?: string[];
|
||||
kill_switch?: components["schemas"]["AgentKillSwitchConfig"] | null;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const ACTION_TONE: Record<string, StatusTone> = {
|
|||
updated: "info",
|
||||
deleted: "error",
|
||||
rotated: "warning",
|
||||
kill_switch_fired: "error",
|
||||
};
|
||||
|
||||
function CopyableJsonBlock({ label, value }: { label: string; value: Record<string, any> }) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const ACTION_OPTIONS = [
|
|||
{ label: "Updated", value: "updated" },
|
||||
{ label: "Deleted", value: "deleted" },
|
||||
{ label: "Rotated", value: "rotated" },
|
||||
{ label: "Kill switch fired", value: "kill_switch_fired" },
|
||||
] as const;
|
||||
|
||||
const TABLE_OPTIONS = [
|
||||
|
|
@ -45,6 +46,7 @@ const TABLE_OPTIONS = [
|
|||
{ label: "Users", value: "LiteLLM_UserTable" },
|
||||
{ label: "Organizations", value: "LiteLLM_OrganizationTable" },
|
||||
{ label: "Models", value: "LiteLLM_ProxyModelTable" },
|
||||
{ label: "Agents", value: "LiteLLM_AgentsTable" },
|
||||
] as const;
|
||||
|
||||
const ACTION_FILTER_ITEMS = [
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const AUDIT_TABLE_NAME_DISPLAY: Record<string, string> = {
|
|||
LiteLLM_UserTable: "Users",
|
||||
LiteLLM_OrganizationTable: "Organizations",
|
||||
LiteLLM_ProxyModelTable: "Models",
|
||||
LiteLLM_AgentsTable: "Agents",
|
||||
};
|
||||
|
||||
const ACTION_TONE: Record<string, StatusTone> = {
|
||||
|
|
@ -31,9 +32,13 @@ const ACTION_TONE: Record<string, StatusTone> = {
|
|||
updated: "info",
|
||||
deleted: "error",
|
||||
rotated: "warning",
|
||||
kill_switch_fired: "error",
|
||||
};
|
||||
|
||||
const capitalize = (value: string): string => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value);
|
||||
export const auditActionLabel = (action: string): string => {
|
||||
const words = action.replace(/_/g, " ");
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : words;
|
||||
};
|
||||
|
||||
interface AuditLogsTableColumnsDeps {
|
||||
onViewLog: (log: AuditLogEntry) => void;
|
||||
|
|
@ -55,7 +60,7 @@ export const getAuditLogsTableColumns = ({ onViewLog }: AuditLogsTableColumnsDep
|
|||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge tone={ACTION_TONE[row.original.action] ?? "neutral"} label={capitalize(row.original.action)} />
|
||||
<StatusBadge tone={ACTION_TONE[row.original.action] ?? "neutral"} label={auditActionLabel(row.original.action)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,16 +59,22 @@ export function PluginModeProvider({ children, accessToken }: PluginModeProvider
|
|||
useEffect(() => {
|
||||
// Re-fetch whenever the auth token changes (handles login/logout cycles)
|
||||
if (!accessToken) return;
|
||||
let unmounted = false;
|
||||
pluginApiClient
|
||||
.get("/api/plugins", { accessToken })
|
||||
.then((data: Plugin[]) => {
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
if (!unmounted) setPlugins(Array.isArray(data) ? data : []);
|
||||
})
|
||||
.catch(() => {})
|
||||
// Mark loaded even on failure so a stored plugin mode still falls back to
|
||||
// ai-gateway; otherwise a failed fetch would strand the user on a blank
|
||||
// plugin view with no switcher to escape.
|
||||
.finally(() => setLoaded(true));
|
||||
.finally(() => {
|
||||
if (!unmounted) setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
// Once plugins have loaded, fall back to ai-gateway if the persisted mode is
|
||||
|
|
|
|||
149
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
149
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -18192,6 +18192,37 @@ export interface paths {
|
|||
patch: operations["patch_agent_v1_agents__agent_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/agents/{agent_id}/kill_switch": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Trigger Agent Kill Switch
|
||||
* @description Fire the agent's configured kill switch webhook. Proxy admin only.
|
||||
*
|
||||
* LiteLLM only makes the configured HTTP call and reports what came back; it
|
||||
* does not change the agent's state in LiteLLM. Returns 200 when the webhook
|
||||
* answered 2xx, 502 with the same result body otherwise. Every attempt is
|
||||
* written to the audit log as a `kill_switch_fired` row against the agent.
|
||||
*
|
||||
* Example Request:
|
||||
* ```bash
|
||||
* curl -X POST "http://localhost:4000/v1/agents/123e4567-e89b-12d3-a456-426614174000/kill_switch" \
|
||||
* -H "Authorization: Bearer <your_api_key>"
|
||||
* ```
|
||||
*/
|
||||
post: operations["trigger_agent_kill_switch_v1_agents__agent_id__kill_switch_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/agents/{agent_id}/make_public": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -24152,6 +24183,7 @@ export interface components {
|
|||
agent_name: string;
|
||||
/** Extra Headers */
|
||||
extra_headers?: string[] | null;
|
||||
kill_switch?: components["schemas"]["AgentKillSwitchConfig"] | null;
|
||||
/** Litellm Params */
|
||||
litellm_params?: {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -24256,6 +24288,90 @@ export interface components {
|
|||
/** Token */
|
||||
token: string;
|
||||
};
|
||||
/** AgentKillSwitchApiKeyAuth */
|
||||
AgentKillSwitchApiKeyAuth: {
|
||||
/** Api Key */
|
||||
api_key: string;
|
||||
/**
|
||||
* Header Name
|
||||
* @default x-api-key
|
||||
*/
|
||||
header_name: string;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "api_key";
|
||||
};
|
||||
/** AgentKillSwitchBasicAuth */
|
||||
AgentKillSwitchBasicAuth: {
|
||||
/** Password */
|
||||
password: string;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "basic";
|
||||
/** Username */
|
||||
username: string;
|
||||
};
|
||||
/** AgentKillSwitchBearerAuth */
|
||||
AgentKillSwitchBearerAuth: {
|
||||
/** Token */
|
||||
token: string;
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "bearer";
|
||||
};
|
||||
/**
|
||||
* AgentKillSwitchConfig
|
||||
* @description Webhook an admin fires to shut an agent down out of band. LiteLLM only
|
||||
* makes the call; whatever the endpoint does with it is the agent's business.
|
||||
*/
|
||||
AgentKillSwitchConfig: {
|
||||
/** Auth */
|
||||
auth?: (components["schemas"]["AgentKillSwitchBearerAuth"] | components["schemas"]["AgentKillSwitchApiKeyAuth"] | components["schemas"]["AgentKillSwitchBasicAuth"]) | null;
|
||||
/** Body */
|
||||
body?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Headers */
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/**
|
||||
* Method
|
||||
* @default POST
|
||||
* @enum {string}
|
||||
*/
|
||||
method: "POST" | "PUT" | "PATCH" | "DELETE" | "GET";
|
||||
/** Query Params */
|
||||
query_params?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** Url */
|
||||
url: string;
|
||||
};
|
||||
/** AgentKillSwitchResult */
|
||||
AgentKillSwitchResult: {
|
||||
/** Agent Id */
|
||||
agent_id: string;
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/**
|
||||
* Method
|
||||
* @enum {string}
|
||||
*/
|
||||
method: "POST" | "PUT" | "PATCH" | "DELETE" | "GET";
|
||||
/** Response Body */
|
||||
response_body?: string | null;
|
||||
/** Status Code */
|
||||
status_code?: number | null;
|
||||
/** Url */
|
||||
url: string;
|
||||
};
|
||||
/** AgentMakePublicResponse */
|
||||
AgentMakePublicResponse: {
|
||||
/** Message */
|
||||
|
|
@ -24312,6 +24428,7 @@ export interface components {
|
|||
extra_headers?: string[] | null;
|
||||
/** Keys */
|
||||
keys?: components["schemas"]["AgentKeySummary"][] | null;
|
||||
kill_switch?: components["schemas"]["AgentKillSwitchConfig"] | null;
|
||||
/** Litellm Params */
|
||||
litellm_params?: {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -37407,6 +37524,7 @@ export interface components {
|
|||
agent_name?: string;
|
||||
/** Extra Headers */
|
||||
extra_headers?: string[] | null;
|
||||
kill_switch?: components["schemas"]["AgentKillSwitchConfig"] | null;
|
||||
/** Litellm Params */
|
||||
litellm_params?: {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -69943,6 +70061,37 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
trigger_agent_kill_switch_v1_agents__agent_id__kill_switch_post: {
|
||||
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"]["AgentKillSwitchResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
make_agent_public_v1_agents__agent_id__make_public_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue