feat(agents): add optional per-agent kill switch webhook (#42841)

This commit is contained in:
devin-ai-integration[bot] 2026-09-24 18:26:50 -05:00 • committed by GitHub
parent 1dc3b62dbc
commit 4584958574
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 2485 additions and 20 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_AgentsTable" ADD COLUMN IF NOT EXISTS "kill_switch" JSONB;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) */}

View file

@ -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", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)} />
),
},
{

View file

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

View file

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