feat(alerting): add native Microsoft Teams alerting destination (#38367)

* feat(alerting): add native Microsoft Teams alerting destination

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(alerting): preserve active destinations on MS Teams save and confirm health test delivery

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): read persisted alerting destinations at MS Teams save time

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-08-27 16:19:22 -07:00 committed by GitHub
parent 44d84360fb
commit d392e7faae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 627 additions and 164 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string | null>;
}
interface MSTeamsSettingsProps {
accessToken: string | null;
userID: string | null;
userRole: string | null;
alerts: AlertingDestination[];
}
const FIELD_HELP: Record<string, React.ReactNode> = {
MS_TEAMS_WEBHOOK_URL: (
<>
Incoming webhook URL for your Teams channel (Workflows or incoming webhook connector)
<span className="text-destructive"> Required * </span>
</>
),
};
const SENSITIVE_FIELD_PATTERN = /(PASSWORD|SECRET|KEY|TOKEN|URL)/i;
const MSTeamsSettings: React.FC<MSTeamsSettingsProps> = ({ accessToken, userID, userRole, alerts }) => {
const [visibleFields, setVisibleFields] = useState<Record<string, boolean>>({});
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<string, string> = 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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Microsoft Teams Alerting Settings</CardTitle>
<p className="text-sm">
Send LiteLLM alerts to a Microsoft Teams channel via an incoming webhook. Create one from{" "}
<a
href="https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook"
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-4"
>
Microsoft Docs: incoming webhooks
</a>
</p>
</CardHeader>
<CardContent>
{alerts
.filter((alert) => alert.name === "ms_teams")
.map((alert, index) => (
<div key={index} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Object.entries(alert.variables ?? {}).map(([key, value]) => {
const isSensitive = SENSITIVE_FIELD_PATTERN.test(key);
const isVisible = visibleFields[key] || false;
return (
<div key={key} className="space-y-1">
<p className="text-sm">{key}</p>
<InputGroup className="max-w-100">
<InputGroupInput
name={key}
defaultValue={value as string}
type={isSensitive && !isVisible ? "password" : "text"}
/>
{isSensitive && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
onClick={() => toggleFieldVisibility(key)}
aria-label={isVisible ? "Hide credential" : "Show credential"}
>
{isVisible ? <EyeOff /> : <Eye />}
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<div className="text-xs text-muted-foreground italic">{FIELD_HELP[key]}</div>
</div>
);
})}
</div>
))}
<div className="mt-6 flex gap-2">
<Button onClick={() => handleSaveMSTeamsSettings()}>Save Changes</Button>
<Button
variant="secondary"
onClick={async () => {
if (!accessToken) return;
try {
await serviceHealthCheck(accessToken, "ms_teams");
toast.success("MS Teams test alert triggered. Check your Teams channel.");
} catch (error) {
toast.fromError(error);
}
}}
>
Test MS Teams Alerts
</Button>
</div>
</CardContent>
</Card>
);
};
export default MSTeamsSettings;

View file

@ -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<SettingsPageProps> = ({ accessToken, userRole, userID,
<TabsTrigger value="alerting-types">Alerting Types</TabsTrigger>
<TabsTrigger value="alerting-settings">Alerting Settings</TabsTrigger>
<TabsTrigger value="email-alerts">Email Alerts</TabsTrigger>
<TabsTrigger value="ms-teams-alerts">MS Teams Alerts</TabsTrigger>
</TabsList>
<TabsContent value="logging-callbacks" keepMounted>
<LoggingCallbacksTable
@ -607,6 +609,9 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<TabsContent value="email-alerts" keepMounted>
<EmailSettings accessToken={accessToken} premiumUser={premiumUser} alerts={alerts} />
</TabsContent>
<TabsContent value="ms-teams-alerts" keepMounted>
<MSTeamsSettings accessToken={accessToken} userID={userID} userRole={userRole} alerts={alerts} />
</TabsContent>
</Tabs>
</div>

View file

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