diff --git a/litellm/integrations/SlackAlerting/batching_handler.py b/litellm/integrations/SlackAlerting/batching_handler.py index a7febdadacd..1c35a15d5a1 100644 --- a/litellm/integrations/SlackAlerting/batching_handler.py +++ b/litellm/integrations/SlackAlerting/batching_handler.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any, Final from litellm._logging import verbose_proxy_logger +from .ms_teams import MS_TEAMS_ALERTING_DESTINATION, build_ms_teams_payload + if TYPE_CHECKING: from .slack_alerting import SlackAlerting as _SlackAlerting @@ -62,14 +64,17 @@ async def send_to_webhook(slackAlertingInstance: SlackAlertingType, item, count) if count > 1: payload["text"] = f"[Num Alerts: {count}]\n\n{payload['text']}" + request_body: Final = ( + build_ms_teams_payload(payload["text"]) if item.get("format") == MS_TEAMS_ALERTING_DESTINATION else payload + ) response: Final = await slackAlertingInstance.async_http_handler.post( url=item["url"], headers=item["headers"], - data=json.dumps(payload), + data=json.dumps(request_body), ) if response.status_code != 200: - verbose_proxy_logger.debug("Error sending slack alert to url=%s. Error=%s", item["url"], response.text) + verbose_proxy_logger.debug("Error sending alert to url=%s. Error=%s", item["url"], response.text) except Exception as e: - verbose_proxy_logger.debug("Error sending slack alert: %s", e) + verbose_proxy_logger.debug("Error sending alert: %s", e) finally: _print_alerting_payload_warning(payload, slackAlertingInstance=slackAlertingInstance) diff --git a/litellm/integrations/SlackAlerting/ms_teams.py b/litellm/integrations/SlackAlerting/ms_teams.py new file mode 100644 index 00000000000..a8988c045b2 --- /dev/null +++ b/litellm/integrations/SlackAlerting/ms_teams.py @@ -0,0 +1,75 @@ +"""Microsoft Teams alert delivery helpers. + +Teams incoming webhooks (Workflows and legacy connectors) accept an Adaptive +Card wrapped in a message attachment, so alert text is delivered as a single +wrapped TextBlock. +""" + +import os +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +from typing_extensions import ReadOnly, TypedDict + +from litellm.types.integrations.slack_alerting import AlertType + +MS_TEAMS_WEBHOOK_URL_ENV: Final = "MS_TEAMS_WEBHOOK_URL" + +MS_TEAMS_ALERTING_DESTINATION: Final = "ms_teams" + +MS_TEAMS_ALERT_HEADERS: Final[Mapping[str, str]] = MappingProxyType({"Content-type": "application/json"}) + + +class MSTeamsTextBlock(TypedDict): + type: ReadOnly[str] + text: ReadOnly[str] + wrap: ReadOnly[bool] + + +class MSTeamsAdaptiveCard(TypedDict): + type: ReadOnly[str] + version: ReadOnly[str] + body: ReadOnly[tuple[MSTeamsTextBlock, ...]] + + +class MSTeamsAttachment(TypedDict): + contentType: ReadOnly[str] + content: ReadOnly[MSTeamsAdaptiveCard] + + +class MSTeamsMessage(TypedDict): + type: ReadOnly[str] + attachments: ReadOnly[tuple[MSTeamsAttachment, ...]] + + +class MSTeamsAlertText(TypedDict): + text: ReadOnly[str] + + +class MSTeamsQueueItem(TypedDict): + url: ReadOnly[str] + headers: ReadOnly[Mapping[str, str]] + payload: ReadOnly[MSTeamsAlertText] + alert_type: ReadOnly[AlertType] + format: ReadOnly[str] + + +def get_ms_teams_webhook_url() -> str | None: + return os.getenv(MS_TEAMS_WEBHOOK_URL_ENV) + + +def build_ms_teams_payload(text: str) -> MSTeamsMessage: + return MSTeamsMessage( + type="message", + attachments=( + MSTeamsAttachment( + contentType="application/vnd.microsoft.card.adaptive", + content=MSTeamsAdaptiveCard( + type="AdaptiveCard", + version="1.4", + body=(MSTeamsTextBlock(type="TextBlock", text=text, wrap=True),), + ), + ), + ), + ) diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index 65f4774a693..2aba8cabe17 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -57,6 +57,13 @@ from litellm.types.proxy.model_deprecation import ( from ..email_templates.templates import * from .batching_handler import send_to_webhook, squash_payloads +from .ms_teams import ( + MS_TEAMS_ALERT_HEADERS, + MS_TEAMS_ALERTING_DESTINATION, + MSTeamsAlertText, + MSTeamsQueueItem, + get_ms_teams_webhook_url, +) from .utils import process_slack_alerting_variables if TYPE_CHECKING: @@ -1431,13 +1438,45 @@ Model Info: # only send budget alerts over Email await self.send_email_alert_using_smtp(webhook_event=user_info, alert_type=alert_type) - if "slack" not in self.alerting: + send_to_slack: Final = "slack" in self.alerting + send_to_ms_teams: Final = MS_TEAMS_ALERTING_DESTINATION in self.alerting + if not send_to_slack and not send_to_ms_teams: return if alert_type not in self.alert_types: return from datetime import datetime + # Get the current timestamp + current_time: Final = datetime.now().strftime("%H:%M:%S") + _proxy_base_url: Final = os.getenv("PROXY_BASE_URL", None) + # Use .name if it's an enum, otherwise use as is + alert_type_name: Final = getattr(alert_type, "name", alert_type) + alert_type_formatted: Final = f"Alert type: `{alert_type_name}`" + if alert_type == "daily_reports" or alert_type == "new_model_added": + formatted_message = alert_type_formatted + message + else: + formatted_message = ( + f"{alert_type_formatted}\nLevel: `{level}`\nTimestamp: `{current_time}`\n\nMessage: {message}" + ) + + if kwargs: + for key, value in kwargs.items(): + formatted_message += f"\n\n{key}: `{value}`\n\n" + if alerting_metadata: + for key, value in alerting_metadata.items(): + formatted_message += f"\n\n*Alerting Metadata*: \n{key}: `{value}`\n\n" + if _proxy_base_url is not None: + formatted_message += f"\n\nProxy URL: `{_proxy_base_url}`" + + if send_to_ms_teams: + self._enqueue_ms_teams_alert(formatted_message=formatted_message, alert_type=alert_type) + + if not send_to_slack: + if len(self.log_queue) >= self.batch_size: + await self.flush_queue() + return + # Check if digest mode is enabled for this alert type alert_type_name_str: Final = getattr(alert_type, "value", str(alert_type)) _atc: Final = self.alert_type_config.get(alert_type_name_str) @@ -1473,28 +1512,6 @@ Model Info: ) return # Suppress immediate alert; will be emitted by _flush_digest_buckets - # Get the current timestamp - current_time: Final = datetime.now().strftime("%H:%M:%S") - _proxy_base_url: Final = os.getenv("PROXY_BASE_URL", None) - # Use .name if it's an enum, otherwise use as is - alert_type_name: Final = getattr(alert_type, "name", alert_type) - alert_type_formatted: Final = f"Alert type: `{alert_type_name}`" - if alert_type == "daily_reports" or alert_type == "new_model_added": - formatted_message = alert_type_formatted + message - else: - formatted_message = ( - f"{alert_type_formatted}\nLevel: `{level}`\nTimestamp: `{current_time}`\n\nMessage: {message}" - ) - - if kwargs: - for key, value in kwargs.items(): - formatted_message += f"\n\n{key}: `{value}`\n\n" - if alerting_metadata: - for key, value in alerting_metadata.items(): - formatted_message += f"\n\n*Alerting Metadata*: \n{key}: `{value}`\n\n" - if _proxy_base_url is not None: - formatted_message += f"\n\nProxy URL: `{_proxy_base_url}`" - # check if we find the slack webhook url in self.alert_to_webhook_url if self.alert_to_webhook_url is not None and alert_type in self.alert_to_webhook_url: slack_webhook_url: str | list[str] | None = self.alert_to_webhook_url[alert_type] @@ -1531,6 +1548,24 @@ Model Info: if len(self.log_queue) >= self.batch_size: await self.flush_queue() + def _enqueue_ms_teams_alert(self, formatted_message: str, alert_type: AlertType) -> None: + ms_teams_webhook_url: Final = get_ms_teams_webhook_url() + if ms_teams_webhook_url is None: + verbose_proxy_logger.error( + "MS Teams alerting is enabled but MS_TEAMS_WEBHOOK_URL is not set. Dropping alert type=%s", + alert_type, + ) + return + payload: Final[MSTeamsAlertText] = {"text": formatted_message} + item: Final[MSTeamsQueueItem] = { + "url": ms_teams_webhook_url, + "headers": MS_TEAMS_ALERT_HEADERS, + "payload": payload, + "alert_type": alert_type, + "format": MS_TEAMS_ALERTING_DESTINATION, + } + self.log_queue.append(item) + async def async_send_batch(self): if not self.log_queue: return diff --git a/litellm/proxy/config_resolvers/alerting.py b/litellm/proxy/config_resolvers/alerting.py index afc0dd924ec..4de7197f88b 100644 --- a/litellm/proxy/config_resolvers/alerting.py +++ b/litellm/proxy/config_resolvers/alerting.py @@ -25,3 +25,7 @@ EMAIL_DESCRIPTORS: Final[tuple[FieldDescriptor, ...]] = ( SLACK_DESCRIPTORS: Final[tuple[FieldDescriptor, ...]] = ( FieldDescriptor("SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", is_secret=True), ) + +MS_TEAMS_DESCRIPTORS: Final[tuple[FieldDescriptor, ...]] = ( + FieldDescriptor("MS_TEAMS_WEBHOOK_URL", "MS_TEAMS_WEBHOOK_URL", "MS_TEAMS_WEBHOOK_URL", is_secret=True), +) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 72688ade228..88aa55fd4a9 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -1,5 +1,6 @@ import asyncio import copy +import json import logging import os import secrets @@ -11,10 +12,16 @@ from typing import Any, Final, Literal, TypedDict, cast import fastapi from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from typing_extensions import ReadOnly import litellm from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.constants import HEALTH_CHECK_TIMEOUT_SECONDS +from litellm.integrations.SlackAlerting.ms_teams import ( + MS_TEAMS_ALERT_HEADERS, + build_ms_teams_payload, + get_ms_teams_webhook_url, +) from litellm.litellm_core_utils.custom_logger_registry import CustomLoggerRegistry from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy._types import ( @@ -164,6 +171,7 @@ services = ( "langfuse", "langfuse_otel", "slack", + "ms_teams", "openmeter", "webhook", "email", @@ -180,6 +188,15 @@ services = ( ) +class _ServiceTestErrorDetail(TypedDict): + error: ReadOnly[str] + + +class _ServiceTestSuccessResponse(TypedDict): + status: ReadOnly[str] + message: ReadOnly[str] + + @router.get( "/test", tags=["health"], @@ -238,6 +255,7 @@ async def health_services_endpoint( "langfuse", "langfuse_otel", "slack", + "ms_teams", "openmeter", "webhook", "braintrust", @@ -448,6 +466,38 @@ async def health_services_endpoint( status_code=422, detail={"error": f'"{service}" not in proxy config: general_settings. Unable to test this.'}, ) + if service == "ms_teams": + if "ms_teams" not in general_settings.get("alerting", ()): + not_configured_detail: Final[_ServiceTestErrorDetail] = { + "error": f'"{service}" not in proxy config: general_settings. Unable to test this.' + } + raise HTTPException(status_code=422, detail=not_configured_detail) + ms_teams_webhook_url: Final = get_ms_teams_webhook_url() + if ms_teams_webhook_url is None: + missing_webhook_detail: Final[_ServiceTestErrorDetail] = { + "error": "MS_TEAMS_WEBHOOK_URL not set. Unable to test this." + } + raise HTTPException(status_code=422, detail=missing_webhook_detail) + ms_teams_test_message: Final = ( + f"Alert type: `{AlertType.budget_alerts.value}`\nLevel: `Low`\n" + f"Timestamp: `{datetime.now().strftime('%H:%M:%S')}`\n\n" + "Message: This is a test MS Teams alert message" + ) + ms_teams_response: Final = await proxy_logging_obj.slack_alerting_instance.async_http_handler.post( + url=ms_teams_webhook_url, + headers=dict(MS_TEAMS_ALERT_HEADERS), # mutable-ok: async_http_handler.post only accepts dict headers + data=json.dumps(build_ms_teams_payload(ms_teams_test_message)), + ) + if ms_teams_response.status_code >= 400: + delivery_failed_detail: Final[_ServiceTestErrorDetail] = { + "error": f"MS Teams webhook returned status {ms_teams_response.status_code}: {ms_teams_response.text}" + } + raise HTTPException(status_code=500, detail=delivery_failed_detail) + ms_teams_success: Final[_ServiceTestSuccessResponse] = { + "status": "success", + "message": "Mock MS Teams Alert sent, verify MS Teams Alert Received in your channel", + } + return ms_teams_success if service == "email": webhook_event: Final = WebhookEvent( event="key_created", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index af26a9f669e..d34068d3abe 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -40,7 +40,7 @@ import anyio import websockets import websockets.exceptions from pydantic import BaseModel, Json, JsonValue -from typing_extensions import NotRequired, assert_never +from typing_extensions import NotRequired, ReadOnly, assert_never from litellm._uuid import uuid from litellm.constants import ( @@ -381,6 +381,7 @@ from litellm.proxy.common_utils.user_api_key_cache import ( from litellm.proxy.config_resolvers import resolve_fields from litellm.proxy.config_resolvers.alerting import ( EMAIL_DESCRIPTORS, + MS_TEAMS_DESCRIPTORS, SLACK_DESCRIPTORS, ) from litellm.proxy.container_endpoints.endpoints import router as container_router @@ -16300,6 +16301,11 @@ def _apply_callback_role_gate(entries: list, is_full_admin: bool) -> list: return [{**entry, "variables": _redact_callback_env_vars(entry.get("variables") or {})} for entry in entries] +class _AlertingDestinationEntry(TypedDict): + name: ReadOnly[str] + variables: ReadOnly[Mapping[str, str | None]] + + def _apply_alerting_env_role_gate(env_vars: dict, is_full_admin: bool) -> dict: if is_full_admin: return mask_sensitive_keys(env_vars, _ALERTING_SENSITIVE_VARS) @@ -16949,6 +16955,17 @@ async def get_config( } ) + _ms_teams_values, _ = resolve_fields( + MS_TEAMS_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True + ) + _ms_teams_env_vars: Final = _apply_alerting_env_role_gate(_ms_teams_values, is_full_admin) + + ms_teams_alerting_entry: Final[_AlertingDestinationEntry] = { + "name": "ms_teams", + "variables": _ms_teams_env_vars, + } + alerting_data.append(ms_teams_alerting_entry) + if llm_router is None: _router_settings = {} else: @@ -16958,6 +16975,7 @@ async def get_config( "status": "success", "callbacks": _data_to_return, "alerts": alerting_data, + "active_alerting_destinations": tuple(_alerting), "router_settings": _router_settings, "available_callbacks": all_available_callbacks, } diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index b29773502fa..a199f8a40da 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -765,7 +765,7 @@ class ProxyLogging: alert_type_config=alert_type_config, ) - if self.alerting is not None and "slack" in self.alerting: + if self.alerting is not None and ("slack" in self.alerting or "ms_teams" in self.alerting): # NOTE: ENSURE we only add callbacks when alerting is on # We should NOT add callbacks when alerting is off if ( @@ -2236,7 +2236,7 @@ class ProxyLogging: # do nothing if alerting is not switched on (unless it's a soft_budget alert with team-specific emails) return - if self.alerting is not None and "slack" in self.alerting: + if self.alerting is not None and ("slack" in self.alerting or "ms_teams" in self.alerting): if self.slack_alerting_instance is not None: await self.slack_alerting_instance.budget_alerts( type=type, @@ -2301,17 +2301,17 @@ class ProxyLogging: and isinstance(request_data["metadata"]["alerting_metadata"], dict) ): alerting_metadata = request_data["metadata"]["alerting_metadata"] + if "slack" in self.alerting or "ms_teams" in self.alerting: + await self.slack_alerting_instance.send_alert( + message=message, + level=level, + alert_type=alert_type, + user_info=None, + alerting_metadata=alerting_metadata, + **extra_kwargs, + ) for client in self.alerting: - if client == "slack": - await self.slack_alerting_instance.send_alert( - message=message, - level=level, - alert_type=alert_type, - user_info=None, - alerting_metadata=alerting_metadata, - **extra_kwargs, - ) - elif client == "sentry": + if client == "sentry": if litellm.utils.sentry_sdk_instance is not None: litellm.utils.sentry_sdk_instance.capture_message(formatted_message) else: diff --git a/tests/test_litellm/integrations/SlackAlerting/test_ms_teams.py b/tests/test_litellm/integrations/SlackAlerting/test_ms_teams.py new file mode 100644 index 00000000000..41b7f3b969b --- /dev/null +++ b/tests/test_litellm/integrations/SlackAlerting/test_ms_teams.py @@ -0,0 +1,122 @@ +import json +from typing import Final +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.integrations.SlackAlerting.batching_handler import send_to_webhook +from litellm.integrations.SlackAlerting.ms_teams import ( + MS_TEAMS_ALERTING_DESTINATION, + MS_TEAMS_WEBHOOK_URL_ENV, + build_ms_teams_payload, + get_ms_teams_webhook_url, +) +from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting +from litellm.proxy._types import AlertType + + +def test_build_ms_teams_payload_wraps_text_in_adaptive_card(): + payload: Final = build_ms_teams_payload("hello alert") + assert payload["type"] == "message" + attachment: Final = payload["attachments"][0] + assert attachment["contentType"] == "application/vnd.microsoft.card.adaptive" + card: Final = attachment["content"] + assert card["type"] == "AdaptiveCard" + assert card["body"] == ({"type": "TextBlock", "text": "hello alert", "wrap": True},) + + +def test_get_ms_teams_webhook_url_reads_env(monkeypatch): + monkeypatch.setenv(MS_TEAMS_WEBHOOK_URL_ENV, "https://teams.example/webhook") + assert get_ms_teams_webhook_url() == "https://teams.example/webhook" + monkeypatch.delenv(MS_TEAMS_WEBHOOK_URL_ENV) + assert get_ms_teams_webhook_url() is None + + +@pytest.mark.asyncio +async def test_send_alert_enqueues_ms_teams_item(monkeypatch): + monkeypatch.setenv(MS_TEAMS_WEBHOOK_URL_ENV, "https://teams.example/webhook") + slack_alerting: Final = SlackAlerting(alerting=["ms_teams"]) + await slack_alerting.send_alert( + message="proxy is down", + level="High", + alert_type=AlertType.db_exceptions, + alerting_metadata={}, + ) + assert len(slack_alerting.log_queue) == 1 + item: Final = slack_alerting.log_queue[0] + assert item["url"] == "https://teams.example/webhook" + assert item["format"] == MS_TEAMS_ALERTING_DESTINATION + assert item["alert_type"] == AlertType.db_exceptions + assert "proxy is down" in item["payload"]["text"] + + +@pytest.mark.asyncio +async def test_send_alert_ms_teams_missing_webhook_drops_alert(monkeypatch): + monkeypatch.delenv(MS_TEAMS_WEBHOOK_URL_ENV, raising=False) + slack_alerting: Final = SlackAlerting(alerting=["ms_teams"]) + await slack_alerting.send_alert( + message="proxy is down", + level="High", + alert_type=AlertType.db_exceptions, + alerting_metadata={}, + ) + assert len(slack_alerting.log_queue) == 0 + + +@pytest.mark.asyncio +async def test_send_alert_slack_and_ms_teams_enqueue_both(monkeypatch): + monkeypatch.setenv(MS_TEAMS_WEBHOOK_URL_ENV, "https://teams.example/webhook") + monkeypatch.setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/test") + slack_alerting: Final = SlackAlerting(alerting=["slack", "ms_teams"]) + await slack_alerting.send_alert( + message="proxy is down", + level="High", + alert_type=AlertType.db_exceptions, + alerting_metadata={}, + ) + urls: Final = sorted(item["url"] for item in slack_alerting.log_queue) + assert urls == ["https://hooks.slack.com/services/test", "https://teams.example/webhook"] + + +@pytest.mark.asyncio +async def test_send_to_webhook_posts_adaptive_card_for_ms_teams_items(): + slack_alerting: Final = SlackAlerting(alerting=["ms_teams"]) + mock_response: Final = MagicMock() + mock_response.status_code = 200 + slack_alerting.async_http_handler = MagicMock() + slack_alerting.async_http_handler.post = AsyncMock(return_value=mock_response) + + item: Final = { + "url": "https://teams.example/webhook", + "headers": {"Content-type": "application/json"}, + "payload": {"text": "alert body"}, + "alert_type": AlertType.db_exceptions, + "format": MS_TEAMS_ALERTING_DESTINATION, + } + await send_to_webhook(slackAlertingInstance=slack_alerting, item=item, count=1) + + call_kwargs: Final = slack_alerting.async_http_handler.post.call_args.kwargs + assert call_kwargs["url"] == "https://teams.example/webhook" + sent_body: Final = json.loads(call_kwargs["data"]) + assert sent_body["type"] == "message" + assert sent_body["attachments"][0]["content"]["body"][0]["text"] == "alert body" + + +@pytest.mark.asyncio +async def test_send_to_webhook_keeps_slack_payload_shape(): + slack_alerting: Final = SlackAlerting(alerting=["slack"]) + mock_response: Final = MagicMock() + mock_response.status_code = 200 + slack_alerting.async_http_handler = MagicMock() + slack_alerting.async_http_handler.post = AsyncMock(return_value=mock_response) + + item: Final = { + "url": "https://hooks.slack.com/services/test", + "headers": {"Content-type": "application/json"}, + "payload": {"text": "alert body"}, + "alert_type": AlertType.db_exceptions, + } + await send_to_webhook(slackAlertingInstance=slack_alerting, item=item, count=1) + + call_kwargs: Final = slack_alerting.async_http_handler.post.call_args.kwargs + assert json.loads(call_kwargs["data"]) == {"text": "alert body"} diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index e70a421379c..dcf122745d2 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1,3 +1,4 @@ +import json import time from datetime import datetime, timedelta from types import SimpleNamespace @@ -15,7 +16,7 @@ import litellm import litellm.proxy.health_endpoints._health_endpoints as _health_endpoints_module from litellm.litellm_core_utils.health_check_helpers import TEST_IMAGE_BASE64 -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.health_endpoints._health_endpoints import ( _db_health_readiness_check, @@ -144,9 +145,7 @@ async def test_db_health_transport_error_never_raises(transport_error): result = await _db_health_readiness_check() assert result["status"] == "disconnected" - mock_prisma.attempt_db_reconnect.assert_called_once_with( - reason="health_readiness_check" - ) + mock_prisma.attempt_db_reconnect.assert_called_once_with(reason="health_readiness_check") @pytest.mark.asyncio @@ -176,9 +175,7 @@ async def test_db_health_transport_error_reconnect_succeeds(transport_error): result = await _db_health_readiness_check() assert result["status"] == "connected" - mock_prisma.attempt_db_reconnect.assert_called_once_with( - reason="health_readiness_check" - ) + mock_prisma.attempt_db_reconnect.assert_called_once_with(reason="health_readiness_check") assert mock_prisma.health_check.call_count == 2 @@ -198,9 +195,7 @@ async def test_db_health_transport_error_reconnect_fails(transport_error): """ mock_prisma = MagicMock() mock_prisma.health_check = AsyncMock(side_effect=transport_error) - mock_prisma.attempt_db_reconnect = AsyncMock( - side_effect=RuntimeError("reconnect failed") - ) + mock_prisma.attempt_db_reconnect = AsyncMock(side_effect=RuntimeError("reconnect failed")) _health_endpoints_module.db_health_cache = { "status": "connected", @@ -252,9 +247,7 @@ async def test_health_services_endpoint_sqs(status, error_message): """ with patch("litellm.integrations.sqs.SQSLogger") as MockSQSLogger: mock_instance = MagicMock() - mock_instance.async_health_check = AsyncMock( - return_value={"status": status, "error_message": error_message} - ) + mock_instance.async_health_check = AsyncMock(return_value={"status": status, "error_message": error_message}) MockSQSLogger.return_value = mock_instance result = await health_services_endpoint(service="sqs") @@ -451,14 +444,9 @@ async def test_test_model_connection_loads_config_from_router(): # Verify that config params were loaded and merged # Note: request params override config params, so model from request is used assert model_params.get("api_key") == "resolved-api-key-from-env" - assert ( - model_params.get("api_base") - == "https://resolved-endpoint.openai.azure.com/" - ) + assert model_params.get("api_base") == "https://resolved-endpoint.openai.azure.com/" assert model_params.get("api_version") == "2024-10-21" - assert ( - model_params.get("model") == "gpt-4o" - ) # Request param overrides config param + assert model_params.get("model") == "gpt-4o" # Request param overrides config param # Verify result assert result["status"] == "success" @@ -594,9 +582,7 @@ async def test_test_model_connection_uses_model_info_id_to_disambiguate_duplicat assert ahealth_check_call_args is not None model_params = ahealth_check_call_args.kwargs.get("model_params", {}) - assert model_params.get("api_base") == ( - "https://deployment-B-base.invalid/v1" - ), ( + assert model_params.get("api_base") == ("https://deployment-B-base.invalid/v1"), ( "Expected /health/test_connection to probe deployment B's " "api_base when model_info.id='deployment-B-id' was provided. " f"Got: {model_params.get('api_base')!r}. This means the " @@ -771,14 +757,10 @@ async def test_test_model_connection_uses_loaded_deployment_team_id(): "can_user_make_model_call", wraps=ModelManagementAuthChecks.can_user_make_model_call, ) as spy_auth_check, - patch( - "litellm.proxy.management_endpoints.model_management_endpoints.TeamRepository" - ) as MockTeamRepo, + patch("litellm.proxy.management_endpoints.model_management_endpoints.TeamRepository") as MockTeamRepo, ): mock_team_repo_instance = MagicMock() - mock_team_repo_instance.table.find_unique = AsyncMock( - side_effect=fake_find_unique - ) + mock_team_repo_instance.table.find_unique = AsyncMock(side_effect=fake_find_unique) MockTeamRepo.return_value = mock_team_repo_instance with pytest.raises(HTTPException) as exc_info: @@ -873,14 +855,10 @@ async def test_test_model_connection_uses_loaded_deployment_team_id_via_model_na "can_user_make_model_call", wraps=ModelManagementAuthChecks.can_user_make_model_call, ) as spy_auth_check, - patch( - "litellm.proxy.management_endpoints.model_management_endpoints.TeamRepository" - ) as MockTeamRepo, + patch("litellm.proxy.management_endpoints.model_management_endpoints.TeamRepository") as MockTeamRepo, ): mock_team_repo_instance = MagicMock() - mock_team_repo_instance.table.find_unique = AsyncMock( - side_effect=fake_find_unique - ) + mock_team_repo_instance.table.find_unique = AsyncMock(side_effect=fake_find_unique) MockTeamRepo.return_value = mock_team_repo_instance with pytest.raises(HTTPException) as exc_info: @@ -920,9 +898,7 @@ async def test_test_model_connection_authorizes_on_params_after_health_check_par from litellm.types.router import Deployment marker = "sentinel-from-health-check-params" - mock_can_user_make_model_call = AsyncMock( - side_effect=HTTPException(status_code=403, detail="denied") - ) + mock_can_user_make_model_call = AsyncMock(side_effect=HTTPException(status_code=403, detail="denied")) with ( patch( # test-quality-ok: proxy module global, no injection seam @@ -1005,9 +981,7 @@ async def test_test_model_connection_authorized_team_admin_passes_real_auth(): return SimpleNamespace( model_dump=lambda: LiteLLM_TeamTable( team_id=owner_team_id, - members_with_roles=[ - {"user_id": owner_admin_user_id, "role": "admin"} - ], + members_with_roles=[{"user_id": owner_admin_user_id, "role": "admin"}], ).model_dump() ) return None @@ -1023,9 +997,7 @@ async def test_test_model_connection_authorized_team_admin_passes_real_auth(): "can_user_make_model_call", wraps=ModelManagementAuthChecks.can_user_make_model_call, ) as spy_auth_check, - patch( - "litellm.proxy.management_endpoints.model_management_endpoints.TeamRepository" - ) as MockTeamRepo, + patch("litellm.proxy.management_endpoints.model_management_endpoints.TeamRepository") as MockTeamRepo, patch( "litellm.proxy.health_endpoints._health_endpoints.litellm.ahealth_check", AsyncMock(return_value=health_result), @@ -1036,9 +1008,7 @@ async def test_test_model_connection_authorized_team_admin_passes_real_auth(): ), ): mock_team_repo_instance = MagicMock() - mock_team_repo_instance.table.find_unique = AsyncMock( - side_effect=fake_find_unique - ) + mock_team_repo_instance.table.find_unique = AsyncMock(side_effect=fake_find_unique) MockTeamRepo.return_value = mock_team_repo_instance result = await health_test_model_connection( @@ -1065,9 +1035,7 @@ async def test_test_model_connection_authorized_team_admin_passes_real_auth(): async def test_health_services_endpoint_galileo(status, error_message): with patch("litellm.integrations.galileo.GalileoObserve") as MockGalileoObserve: mock_instance = MagicMock() - mock_instance.async_health_check = AsyncMock( - return_value={"status": status, "error_message": error_message} - ) + mock_instance.async_health_check = AsyncMock(return_value={"status": status, "error_message": error_message}) MockGalileoObserve.return_value = mock_instance result = await health_services_endpoint(service="galileo") @@ -1140,13 +1108,9 @@ async def test_health_services_endpoint_newrelic_blocks_non_admin(role): user_role=role, ) - with patch( - "litellm.integrations.newrelic.newrelic.NewRelicLogger" - ) as MockNewRelicLogger: + with patch("litellm.integrations.newrelic.newrelic.NewRelicLogger") as MockNewRelicLogger: mock_instance = MagicMock() - mock_instance.async_health_check = AsyncMock( - return_value={"status": "healthy", "error_message": ""} - ) + mock_instance.async_health_check = AsyncMock(return_value={"status": "healthy", "error_message": ""}) MockNewRelicLogger.return_value = mock_instance with pytest.raises(ProxyException) as exc_info: @@ -1175,13 +1139,9 @@ async def test_health_services_endpoint_newrelic_allows_proxy_admin(admin_role): user_role=admin_role, ) - with patch( - "litellm.integrations.newrelic.newrelic.NewRelicLogger" - ) as MockNewRelicLogger: + with patch("litellm.integrations.newrelic.newrelic.NewRelicLogger") as MockNewRelicLogger: mock_instance = MagicMock() - mock_instance.async_health_check = AsyncMock( - return_value={"status": "healthy", "error_message": ""} - ) + mock_instance.async_health_check = AsyncMock(return_value={"status": "healthy", "error_message": ""}) MockNewRelicLogger.return_value = mock_instance result = await health_services_endpoint( @@ -1232,20 +1192,14 @@ def test_health_liveliness_endpoint(proxy_client): duration_ms = (end_time - start_time) * 1000 # Assert response status - assert ( - response.status_code == 200 - ), f"Expected 200 OK, got {response.status_code}: {response.text}" + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}: {response.text}" # Assert response content (FastAPI JSON-encodes the string) - assert ( - response.json() == "I'm alive!" - ), f"Expected 'I'm alive!' message, got: {response.json()}" + assert response.json() == "I'm alive!", f"Expected 'I'm alive!' message, got: {response.json()}" # Verify response is fast (should be < 100ms for a simple endpoint) # This is critical for orchestration systems that poll frequently - assert ( - duration_ms < 100 - ), f"Health check took {duration_ms:.2f}ms, expected < 100ms for a simple endpoint" + assert duration_ms < 100, f"Health check took {duration_ms:.2f}ms, expected < 100ms for a simple endpoint" # Log the duration for visibility (useful for CI/CD monitoring) print(f"\n/health/liveliness response time: {duration_ms:.2f}ms") @@ -1265,19 +1219,13 @@ def test_health_liveness_endpoint(proxy_client): duration_ms = (end_time - start_time) * 1000 # Assert response status - assert ( - response.status_code == 200 - ), f"Expected 200 OK, got {response.status_code}: {response.text}" + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}: {response.text}" # Assert response content (FastAPI JSON-encodes the string) - assert ( - response.json() == "I'm alive!" - ), f"Expected 'I'm alive!' message, got: {response.json()}" + assert response.json() == "I'm alive!", f"Expected 'I'm alive!' message, got: {response.json()}" # Verify response is fast (should be < 100ms for a simple endpoint) - assert ( - duration_ms < 100 - ), f"Health check took {duration_ms:.2f}ms, expected < 100ms for a simple endpoint" + assert duration_ms < 100, f"Health check took {duration_ms:.2f}ms, expected < 100ms for a simple endpoint" # Log the duration for visibility (useful for CI/CD monitoring) print(f"\n/health/liveness response time: {duration_ms:.2f}ms") @@ -1298,15 +1246,11 @@ def test_health_readiness(proxy_client): duration_ms = (end_time - start_time) * 1000 # Assert response status - assert ( - response.status_code == 200 - ), f"Expected 200 OK, got {response.status_code}: {response.text}" + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}: {response.text}" # Verify response is fast (readiness may include DB check if available, so < 500ms is reasonable) # This is critical for orchestration systems (Kubernetes) that poll frequently - assert ( - duration_ms < 500 - ), f"Health check took {duration_ms:.2f}ms, expected < 500ms for readiness endpoint" + assert duration_ms < 500, f"Health check took {duration_ms:.2f}ms, expected < 500ms for readiness endpoint" # Assert response contains only low-detail public probe fields. `db` is # included so unauthenticated probes can distinguish "DB unreachable" @@ -1325,9 +1269,7 @@ def test_health_readiness_details_returns_diagnostic_fields(monkeypatch): """ app = FastAPI() app.include_router(_health_endpoints_module.router) - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( - user_role=LitellmUserRoles.PROXY_ADMIN - ) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) client = TestClient(app) monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) @@ -1477,9 +1419,7 @@ def test_get_callback_identifier_custom_logger_registry_and_fallback(): unregistered = UnregisteredCallback() # Mock registry to return empty list (not registered) - with patch.object( - CustomLoggerRegistry, "get_all_callback_strs_from_class_type", return_value=[] - ): + with patch.object(CustomLoggerRegistry, "get_all_callback_strs_from_class_type", return_value=[]): result = get_callback_identifier(unregistered) # Should fall back to callback_name() which returns __class__.__name__ assert result == "UnregisteredCallback" @@ -1568,13 +1508,9 @@ async def test_health_endpoint_filters_model_list_by_user_access(): await health_endpoint(response=Response(), user_api_key_dict=user_api_key_dict) - assert ( - "model_list" in captured - ), "health_endpoint did not call _perform_health_check_and_save" + assert "model_list" in captured, "health_endpoint did not call _perform_health_check_and_save" returned_names = {m["model_name"] for m in captured["model_list"]} - assert returned_names == { - "model-a" - }, f"health_endpoint did not scope model_list to caller access: {returned_names}" + assert returned_names == {"model-a"}, f"health_endpoint did not scope model_list to caller access: {returned_names}" @pytest.mark.asyncio @@ -1704,9 +1640,7 @@ async def test_health_endpoint_resolves_all_team_models_to_team_allowlist(): await health_endpoint(response=Response(), user_api_key_dict=user_api_key_dict) returned_names = {m["model_name"] for m in captured["model_list"]} - assert returned_names == { - "model-b" - }, f"all-team-models key should health-check the team's models: {returned_names}" + assert returned_names == {"model-b"}, f"all-team-models key should health-check the team's models: {returned_names}" @pytest.mark.asyncio @@ -1788,15 +1722,13 @@ async def test_health_endpoint_filters_background_cache_by_user_access(): # vacuously when the cache filter drops everything because cached # entries lack the model_id key — both entries carry model_id above.) assert len(cached_results["healthy_endpoints"]) == 2 - assert all( - ep.get("model_id") for ep in cached_results["healthy_endpoints"] - ), "test fixture invariant: every cached entry must carry a model_id" + assert all(ep.get("model_id") for ep in cached_results["healthy_endpoints"]), ( + "test fixture invariant: every cached entry must carry a model_id" + ) # The non-admin caller must not see api_base on the returned cache entries. returned = result.get("healthy_endpoints", []) - assert ( - len(returned) == 1 - ), f"expected exactly one cached entry after scoping, got {len(returned)}" + assert len(returned) == 1, f"expected exactly one cached entry after scoping, got {len(returned)}" assert returned[0]["model_id"] == "id-a" assert "api_base" not in returned[0] assert result["healthy_count"] == 1 @@ -1887,13 +1819,12 @@ async def test_health_endpoint_admin_sees_routing_fields_non_admin_does_not(): non_admin_eps = non_admin_result.get("healthy_endpoints", []) assert len(admin_eps) == 1 - assert ( - admin_eps[0]["api_base"] - == "https://us-central1-aiplatform.googleapis.com/v1/projects/p" - ), "admin must see the full api_base so they can identify the region" - assert ( - admin_eps[0]["api_version"] == "2024-10-21" - ), "admin must see api_version so they can distinguish provider deployments" + assert admin_eps[0]["api_base"] == "https://us-central1-aiplatform.googleapis.com/v1/projects/p", ( + "admin must see the full api_base so they can identify the region" + ) + assert admin_eps[0]["api_version"] == "2024-10-21", ( + "admin must see api_version so they can distinguish provider deployments" + ) assert len(non_admin_eps) == 1 assert "api_base" not in non_admin_eps[0] @@ -1910,10 +1841,7 @@ async def test_health_endpoint_admin_sees_routing_fields_non_admin_does_not(): # Stripping must produce a copy — the shared cache must still carry the # routing fields so the next admin caller can read them. cached_first = cached_results["healthy_endpoints"][0] - assert ( - cached_first["api_base"] - == "https://us-central1-aiplatform.googleapis.com/v1/projects/p" - ) + assert cached_first["api_base"] == "https://us-central1-aiplatform.googleapis.com/v1/projects/p" assert cached_first["api_version"] == "2024-10-21" @@ -2058,9 +1986,7 @@ async def test_health_endpoint_blocks_cross_scope_model_id_under_background_cach leaked_ids = {ep.get("model_id") for ep in result.get("healthy_endpoints", [])} leaked_ids |= {ep.get("model_id") for ep in result.get("unhealthy_endpoints", [])} - assert ( - "id-b" not in leaked_ids - ), "background cache leaked an out-of-scope deployment to a scoped caller" + assert "id-b" not in leaked_ids, "background cache leaked an out-of-scope deployment to a scoped caller" assert result["healthy_count"] == 0 assert response.status_code == 503 @@ -2287,9 +2213,7 @@ async def test_health_endpoint_no_model_param_returns_200_even_when_zero_healthy async def fake_perform(**kwargs): return { "healthy_endpoints": [], - "unhealthy_endpoints": [ - {"model": "openai/gpt-4o", "model_id": "id-a", "error": "boom"} - ], + "unhealthy_endpoints": [{"model": "openai/gpt-4o", "model_id": "id-a", "error": "boom"}], "healthy_count": 0, "unhealthy_count": 1, } @@ -2747,6 +2671,70 @@ class TestNoRedisWarning: assert details["show_no_redis_warning"] is False +@pytest.mark.asyncio +async def test_health_services_endpoint_ms_teams_posts_adaptive_card(): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_post = AsyncMock(return_value=mock_response) + mock_proxy_logging = MagicMock() + mock_proxy_logging.slack_alerting_instance.async_http_handler.post = mock_post + + with ( + patch( # test-quality-ok: endpoint reads proxy_server module globals, same pattern as sibling tests + "litellm.proxy.proxy_server.general_settings", + {"alerting": ["ms_teams"]}, + ), + patch( # test-quality-ok: endpoint reads proxy_server module globals, same pattern as sibling tests + "litellm.proxy.proxy_server.proxy_logging_obj", + mock_proxy_logging, + ), + patch.dict("os.environ", {"MS_TEAMS_WEBHOOK_URL": "https://teams.example/webhook"}), + ): + result = await health_services_endpoint(service="ms_teams") + + assert result["status"] == "success" + call_kwargs = mock_post.call_args.kwargs + assert call_kwargs["url"] == "https://teams.example/webhook" + sent_body = json.loads(call_kwargs["data"]) + assert sent_body["type"] == "message" + assert sent_body["attachments"][0]["contentType"] == "application/vnd.microsoft.card.adaptive" + + +@pytest.mark.asyncio +async def test_health_services_endpoint_ms_teams_surfaces_delivery_failure(): + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Invalid webhook" + mock_proxy_logging = MagicMock() + mock_proxy_logging.slack_alerting_instance.async_http_handler.post = AsyncMock(return_value=mock_response) + + with ( + patch( # test-quality-ok: endpoint reads proxy_server module globals, same pattern as sibling tests + "litellm.proxy.proxy_server.general_settings", + {"alerting": ["ms_teams"]}, + ), + patch( # test-quality-ok: endpoint reads proxy_server module globals, same pattern as sibling tests + "litellm.proxy.proxy_server.proxy_logging_obj", + mock_proxy_logging, + ), + patch.dict("os.environ", {"MS_TEAMS_WEBHOOK_URL": "https://teams.example/webhook"}), + ): + with pytest.raises(ProxyException) as exc_info: + await health_services_endpoint(service="ms_teams") + + assert "status 400" in str(exc_info.value.message) + + +@pytest.mark.asyncio +async def test_health_services_endpoint_ms_teams_requires_alerting_config(): + with patch( # test-quality-ok: endpoint reads proxy_server module globals, same pattern as sibling tests + "litellm.proxy.proxy_server.general_settings", + {"alerting": ["slack"]}, + ): + with pytest.raises(ProxyException): + await health_services_endpoint(service="ms_teams") + + def test_test_model_connection_accepts_image_edit_mode(monkeypatch): """ Regression: /health/test_connection rejected mode=image_edit with a 422 diff --git a/ui/litellm-dashboard/src/components/MSTeamsSettings.tsx b/ui/litellm-dashboard/src/components/MSTeamsSettings.tsx new file mode 100644 index 00000000000..cdbd04de74a --- /dev/null +++ b/ui/litellm-dashboard/src/components/MSTeamsSettings.tsx @@ -0,0 +1,161 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { Eye, EyeOff } from "lucide-react"; +import { toast } from "@/lib/toast"; +import { getCallbacksCall, serviceHealthCheck, setCallbacksCall } from "./networking"; + +interface AlertingDestination { + name: string; + variables?: Record; +} + +interface MSTeamsSettingsProps { + accessToken: string | null; + userID: string | null; + userRole: string | null; + alerts: AlertingDestination[]; +} + +const FIELD_HELP: Record = { + MS_TEAMS_WEBHOOK_URL: ( + <> + Incoming webhook URL for your Teams channel (Workflows or incoming webhook connector) + Required * + + ), +}; + +const SENSITIVE_FIELD_PATTERN = /(PASSWORD|SECRET|KEY|TOKEN|URL)/i; + +const MSTeamsSettings: React.FC = ({ accessToken, userID, userRole, alerts }) => { + const [visibleFields, setVisibleFields] = useState>({}); + + const toggleFieldVisibility = (key: string) => { + setVisibleFields((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + const handleSaveMSTeamsSettings = async () => { + if (!accessToken || !userID || !userRole) { + return; + } + + // Only send fields the admin actually edited. Values rendered from the + // server are masked or sourced from the process environment, so + // re-submitting an untouched field would persist a mask or copy + // env-managed config into the database. + const updatedVariables: Record = Object.fromEntries( + alerts + .filter((alert) => alert.name === "ms_teams") + .flatMap((alert) => + Object.entries(alert.variables ?? {}).flatMap(([key, value]) => { + const inputElement = document.querySelector(`input[name="${key}"]`) as HTMLInputElement; + if (!inputElement || !inputElement.value) { + return []; + } + if (inputElement.value === (value == null ? "" : String(value))) { + return []; + } + return [[key, inputElement.value] as const]; + }), + ), + ); + + try { + // Re-read the persisted destinations at save time so that a Teams save + // never restores destinations another form disabled after page load. + const currentConfig = await getCallbacksCall(accessToken, userID, userRole); + const currentDestinations: string[] = currentConfig.active_alerting_destinations ?? []; + const payload = { + general_settings: { + alerting: Array.from(new Set([...currentDestinations, "ms_teams"])), + }, + environment_variables: updatedVariables, + }; + await setCallbacksCall(accessToken, payload); + toast.success("MS Teams settings updated successfully"); + } catch (error) { + toast.fromError(error); + } + }; + + return ( + + + Microsoft Teams Alerting Settings +

+ Send LiteLLM alerts to a Microsoft Teams channel via an incoming webhook. Create one from{" "} + + Microsoft Docs: incoming webhooks + +

+
+ + + {alerts + .filter((alert) => alert.name === "ms_teams") + .map((alert, index) => ( +
+ {Object.entries(alert.variables ?? {}).map(([key, value]) => { + const isSensitive = SENSITIVE_FIELD_PATTERN.test(key); + const isVisible = visibleFields[key] || false; + return ( +
+

{key}

+ + + {isSensitive && ( + + toggleFieldVisibility(key)} + aria-label={isVisible ? "Hide credential" : "Show credential"} + > + {isVisible ? : } + + + )} + +
{FIELD_HELP[key]}
+
+ ); + })} +
+ ))} + +
+ + +
+
+
+ ); +}; + +export default MSTeamsSettings; diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index e6337cef9bd..05e66985e6f 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import EmailSettings from "./email_settings"; +import MSTeamsSettings from "./MSTeamsSettings"; import { Logo } from "@/components/molecules/logo/Logo"; import { toast } from "@/lib/toast"; @@ -490,6 +491,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, Alerting Types Alerting Settings Email Alerts + MS Teams Alerts = ({ accessToken, userRole, userID, + + + diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 25fbd53018a..871c0e5b2ce 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -47157,7 +47157,7 @@ export interface operations { parameters: { query: { /** @description Specify the service being hit. */ - service: ("slack_budget_alerts" | "langfuse" | "langfuse_otel" | "slack" | "openmeter" | "webhook" | "email" | "braintrust" | "datadog" | "datadog_llm_observability" | "generic_api" | "arize" | "galileo" | "newrelic" | "sqs") | string; + service: ("slack_budget_alerts" | "langfuse" | "langfuse_otel" | "slack" | "ms_teams" | "openmeter" | "webhook" | "email" | "braintrust" | "datadog" | "datadog_llm_observability" | "generic_api" | "arize" | "galileo" | "newrelic" | "sqs") | string; }; header?: never; path?: never;