diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index b36c8a038fce..1ee1ecfd6cd6 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -24,6 +24,9 @@ is_invalid_virtual_key_error, mark_invalid_virtual_key_error, ) +from litellm.proxy.common_utils.http_parsing_utils import ( + _safe_get_request_headers, # pyright: ignore[reportPrivateUsage] # canonical non-throwing request header reader +) from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.types.services import ServiceTypes @@ -74,17 +77,35 @@ def _as_proxy_exception(e: Exception) -> ProxyException: ) -def _with_requester_ip_address(request_data: dict[str, object], requester_ip: str | None) -> dict[str, object]: - """Auth gate rejections are raised before `add_litellm_data_to_request` records the - caller IP, so their failure logs would otherwise carry no IP nor key/user identity.""" +def _with_auth_failure_metadata( + request_data: dict[str, object], + requester_ip: str | None, + spend_logs_metadata: Mapping[str, object] | None, +) -> dict[str, object]: + """Add metadata normally populated after auth without mutating the request body.""" + raw_spend_metadata: Final = request_data.get("metadata") + spend_metadata_base: Final[Mapping[str, object]] = ( # pyright: ignore[reportUnknownVariableType] # request JSON mappings have string keys + raw_spend_metadata if isinstance(raw_spend_metadata, Mapping) else EMPTY_MAPPING + ) + request_data_with_spend_metadata: Final = ( + { + **request_data, + "metadata": {**spend_metadata_base, "spend_logs_metadata": spend_logs_metadata}, + } + if spend_logs_metadata is not None + else request_data + ) # mutable-ok: logging hooks require plain dictionaries if not requester_ip: - return request_data - key: Final = "litellm_metadata" if "litellm_metadata" in request_data else "metadata" - metadata: Final = request_data.get(key) + return request_data_with_spend_metadata + key: Final = "litellm_metadata" if "litellm_metadata" in request_data_with_spend_metadata else "metadata" + metadata: Final = request_data_with_spend_metadata.get(key) base: Final[Mapping[str, object]] = metadata if isinstance(metadata, Mapping) else EMPTY_MAPPING if base.get("requester_ip_address"): - return request_data - return {**request_data, key: {**base, "requester_ip_address": requester_ip}} # mutable-ok: logging needs dicts + return request_data_with_spend_metadata + return { + **request_data_with_spend_metadata, + key: {**base, "requester_ip_address": requester_ip}, + } # mutable-ok: logging needs dicts class UserAPIKeyAuthExceptionHandler: @@ -199,8 +220,17 @@ async def _handle_authentication_error( ) # Allow callbacks to transform the error response + from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup + + spend_logs_metadata: Final = LiteLLMProxyRequestSetup.get_spend_logs_metadata_from_request_headers( + _safe_get_request_headers(request) + ) transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook( - request_data=_with_requester_ip_address(request_data, requester_ip), + request_data=_with_auth_failure_metadata( + request_data=request_data, + requester_ip=requester_ip, + spend_logs_metadata=spend_logs_metadata, + ), original_exception=e, user_api_key_dict=user_api_key_dict, error_type=ProxyErrorTypes.auth_error, diff --git a/litellm/proxy/db/health_check_latest.py b/litellm/proxy/db/health_check_latest.py index 35bc838379cf..21438f095bb7 100644 --- a/litellm/proxy/db/health_check_latest.py +++ b/litellm/proxy/db/health_check_latest.py @@ -74,10 +74,14 @@ def _assume_utc(cls, value: datetime) -> datetime: _ROWS_ADAPTER: Final = TypeAdapter(tuple[LatestHealthCheckRow, ...]) +async def query_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]: + rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL) + return _ROWS_ADAPTER.validate_python(rows) + + async def fetch_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]: try: - rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL) - return _ROWS_ADAPTER.validate_python(rows) + return await query_latest_health_checks(prisma_client) except Exception as query_err: # noqa: BLE001 # health decorates other reads; a driver error must not fail them verbose_proxy_logger.error("Error getting all latest health checks: %s", query_err) return () diff --git a/litellm/proxy/dd_span_tagger.py b/litellm/proxy/dd_span_tagger.py index 55f3ecf4da31..08a99e363cdf 100644 --- a/litellm/proxy/dd_span_tagger.py +++ b/litellm/proxy/dd_span_tagger.py @@ -36,11 +36,15 @@ def tag_request( - ``litellm.key_alias`` — human-readable alias for the API key - ``litellm.key_hash`` — hashed API key (safe to log; never the raw secret) - ``litellm.requested_model``— model name as sent by the client + - ``litellm.user_email`` — email of the authenticated user Use cases: - Trace all requests from a specific user/key: filter by ``litellm.key_alias`` or ``litellm.key_hash``. - Trace all requests for a specific model: filter by ``litellm.requested_model``. + - Trace all requests from a specific person under JWT auth, where there is no key_alias: + filter by ``@litellm.user_email:"user@example.com"``. The value comes from the JWT claim + mapped by ``litellm_jwtauth.user_email_jwt_field``, or from the user row for virtual keys. Note: key_alias / key_hash are not available for unauthenticated (e.g. 401) requests. """ @@ -51,8 +55,10 @@ def tag_request( set_active_span_tag("litellm.key_hash", str(user_api_key_dict.token)) if requested_model: set_active_span_tag("litellm.requested_model", str(requested_model)) + if user_api_key_dict.user_email: + set_active_span_tag("litellm.user_email", str(user_api_key_dict.user_email)) except Exception: verbose_proxy_logger.debug( - "Failed to tag active ddtrace span with key/model tags", + "Failed to tag active ddtrace span with key/model/user tags", exc_info=True, ) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index db6ec754c6ec..64fd59bbe442 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -45,7 +45,10 @@ from litellm.proxy.auth.model_checks import get_key_models from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler -from litellm.proxy.db.health_check_latest import LatestHealthCheckRow +from litellm.proxy.db.health_check_latest import ( + LatestHealthCheckRow, + query_latest_health_checks, +) from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers from litellm.proxy.health_check import ( ADMIN_ONLY_HEALTH_DISPLAY_PARAMS, @@ -876,7 +879,7 @@ async def _save_background_health_checks_to_db( ) # Step 3: Get latest health checks for all models in one query to compare status - latest_checks: Final = await prisma_client.get_all_latest_health_checks() + latest_checks: Final = await query_latest_health_checks(prisma_client) latest_checks_map: Final = {} for check in latest_checks: # Use model_id as primary key, fallback to model_name diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 59971e54e46e..b031e5b592ac 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -90,6 +90,7 @@ _SESSION_ID_VALUE_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$") _SHA256_HEX_RE: Final = re.compile(r"^[0-9a-f]{64}$") +_SPEND_LOGS_METADATA_ADAPTER: Final = TypeAdapter(dict[str, object]) # W3C Trace Context traceparent header: https://www.w3.org/TR/trace-context/ # e.g. "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" @@ -1256,16 +1257,19 @@ def _get_num_retries_from_request(headers: dict) -> int | None: return None @staticmethod - def _get_spend_logs_metadata_from_request_headers(headers: dict) -> dict | None: + def get_spend_logs_metadata_from_request_headers( + headers: Mapping[str, object], + ) -> dict[str, object] | None: # mutable-ok: request metadata is stored in a TypedDict """ Get the `spend_logs_metadata` from the request headers. """ - from litellm.litellm_core_utils.safe_json_loads import safe_json_loads - spend_logs_metadata_header: Final = headers.get("x-litellm-spend-logs-metadata", None) - if spend_logs_metadata_header is not None: - return safe_json_loads(spend_logs_metadata_header) - return None + if not isinstance(spend_logs_metadata_header, str): + return None + try: + return _SPEND_LOGS_METADATA_ADAPTER.validate_json(spend_logs_metadata_header) + except PydanticValidationError: + return None @staticmethod def _get_forwardable_headers( @@ -1497,7 +1501,7 @@ def add_litellm_metadata_from_request_headers( from litellm.proxy._types import LitellmMetadataFromRequestHeaders metadata_from_headers: Final = LitellmMetadataFromRequestHeaders() - spend_logs_metadata: Final = LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(headers) + spend_logs_metadata: Final = LiteLLMProxyRequestSetup.get_spend_logs_metadata_from_request_headers(headers) if spend_logs_metadata is not None: metadata_from_headers["spend_logs_metadata"] = spend_logs_metadata diff --git a/litellm/router.py b/litellm/router.py index 8865543badde..0582805943e6 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10683,6 +10683,10 @@ def _set_model_group_info(self, model_group: str, user_facing_model_group_name: model_list: Final = self.get_model_list(model_name=model_group) if model_list is None: return None + model_group_description: Final[str | None] = next( + (d for d in (m.get("model_info", {}).get("description") for m in model_list) if isinstance(d, str)), + None, + ) for model in model_list: is_match = False if ( @@ -10787,6 +10791,7 @@ def _set_model_group_info(self, model_group: str, user_facing_model_group_name: **{ "model_group": user_facing_model_group_name, "providers": [llm_provider], + "description": model_group_description, **model_info, "supported_reasoning_efforts": None, } diff --git a/litellm/types/router.py b/litellm/types/router.py index c73635020170..e8a3bb6c01f1 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -721,6 +721,7 @@ class ModelGroupInfo(BaseModel): supported_reasoning_efforts: tuple[str, ...] | None = Field(default=None) supported_openai_params: list[str] | None = Field(default=[]) configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None + description: str | None = None def __init__(self, **data) -> None: for field_name, field_type in _resolved_annotations(self.__class__).items(): diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 21e0b83791f3..8167871aad77 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -1,5 +1,6 @@ import asyncio import json +from copy import deepcopy from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -620,6 +621,94 @@ def _http_request(client_host: str | None = "10.1.2.3", headers: dict[str, str] ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "header_value, has_body_metadata, expected_spend_logs_metadata", + [ + pytest.param( + json.dumps({"my_request_id": "req_rejected_2"}), + True, + {"my_request_id": "req_rejected_2"}, + id="valid_header_overrides_body", + ), + pytest.param( + json.dumps({"my_request_id": "req_rejected_2"}), + False, + {"my_request_id": "req_rejected_2"}, + id="valid_header_without_body_metadata", + ), + pytest.param( + "{invalid-json", + True, + {"source": "request-body"}, + id="invalid_header_keeps_body", + ), + pytest.param( + "null", + True, + {"source": "request-body"}, + id="null_header_keeps_body", + ), + pytest.param( + None, + True, + {"source": "request-body"}, + id="missing_headers_scope_keeps_body", + ), + ], +) +async def test_budget_auth_failure_logs_spend_metadata_from_request_header( + header_value: str | None, + has_body_metadata: bool, + expected_spend_logs_metadata: dict[str, str], +) -> None: + request_data: dict[str, object] = {"model": "gpt-4o"} + if has_body_metadata: + request_data["metadata"] = { + "existing": "keep-me", + "spend_logs_metadata": {"source": "request-body"}, + } + original_request_data = deepcopy(request_data) + request = _http_request( + headers={"x-litellm-spend-logs-metadata": header_value} if header_value is not None else None + ) + if header_value is None: + request.scope.pop("headers") + + with ( + patch( # test-quality-ok: identity seeding is outside the failure-log payload contract + "litellm.proxy.auth.auth_exception_handler.seed_request_identity" + ), + patch( # test-quality-ok: capture the exact payload sent to the spend logger + "litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook", + new_callable=AsyncMock, + return_value=None, + ) as mock_hook, + patch( # test-quality-ok: disable the unrelated database-outage fallback + "litellm.proxy.proxy_server.general_settings", + {"allow_requests_on_db_unavailable": False}, + ), + ): + with pytest.raises(ProxyException) as exc_info: + await UserAPIKeyAuthExceptionHandler._handle_authentication_error( + BudgetExceededError(message="Budget exceeded", current_cost=100, max_budget=100), + request, + request_data, + "/v1/chat/completions", + None, + "sk-over-budget", + ) + + assert exc_info.value.code == str(status.HTTP_429_TOO_MANY_REQUESTS) + assert exc_info.value.type == ProxyErrorTypes.budget_exceeded + logged_request_data = mock_hook.call_args.kwargs["request_data"] + assert logged_request_data["metadata"]["spend_logs_metadata"] == expected_spend_logs_metadata + assert logged_request_data["metadata"]["requester_ip_address"] == "10.1.2.3" + if has_body_metadata: + assert logged_request_data["metadata"]["existing"] == "keep-me" + assert request_data == original_request_data + + @pytest.mark.asyncio @pytest.mark.parametrize( "auth_error, general_settings, request_kwargs, expected_ip", diff --git a/tests/test_litellm/proxy/db/test_health_check_latest.py b/tests/test_litellm/proxy/db/test_health_check_latest.py index 529e4d8f2e94..6322891ae9ec 100644 --- a/tests/test_litellm/proxy/db/test_health_check_latest.py +++ b/tests/test_litellm/proxy/db/test_health_check_latest.py @@ -8,6 +8,7 @@ LATEST_HEALTH_CHECKS_SQL, fetch_latest_health_checks, fetch_latest_health_checks_for_models, + query_latest_health_checks, ) @@ -83,6 +84,15 @@ async def test_fetch_all_degrades_to_no_rows_when_the_query_fails(): assert await fetch_latest_health_checks(prisma) == () +@pytest.mark.asyncio +async def test_query_all_raises_when_the_query_fails_instead_of_reading_as_an_empty_table(): + """The background save decides what to write from this read; a failure has to be told apart from no rows.""" + prisma = _prisma([]) + prisma.db.query_raw.side_effect = RuntimeError("db down") + with pytest.raises(RuntimeError, match="db down"): + await query_latest_health_checks(prisma) + + @pytest.mark.asyncio async def test_fetch_all_degrades_to_no_rows_for_a_malformed_row(): assert await fetch_latest_health_checks(_prisma([{"unexpected": "shape"}])) == () diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 69e89d1c6048..ca96f1e14646 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -3177,12 +3177,13 @@ def test_streaming_overhead_header_in_custom_headers_from_stream_hidden_params( class TestDDSpanTaggerTagRequest: """Tests for DDSpanTagger.tag_request - key/model DD span tagging.""" - def _make_user_api_key_dict(self, key_alias=None, token=None): + def _make_user_api_key_dict(self, key_alias=None, token=None, user_email=None): from litellm.proxy._types import UserAPIKeyAuth d = UserAPIKeyAuth() d.key_alias = key_alias d.token = token + d.user_email = user_email return d def test_tags_key_alias_and_model(self): @@ -3223,6 +3224,30 @@ def test_only_model_tagged_when_no_key_info(self): mock_set_tag.assert_called_once_with("litellm.requested_model", "claude-3-5-sonnet") + def test_tags_user_email(self): + """user_email is tagged so JWT-authenticated requests are traceable per person.""" + user_key = self._make_user_api_key_dict(user_email="user@example.com") + + with patch("litellm.proxy.dd_span_tagger.set_active_span_tag") as mock_set_tag: + DDSpanTagger.tag_request( + user_api_key_dict=user_key, + requested_model=None, + ) + + mock_set_tag.assert_called_once_with("litellm.user_email", "user@example.com") + + def test_no_user_email_tag_when_absent(self): + """No user email tag when the authenticated identity has no email.""" + user_key = self._make_user_api_key_dict(key_alias="my-prod-key", user_email=None) + + with patch("litellm.proxy.dd_span_tagger.set_active_span_tag") as mock_set_tag: + DDSpanTagger.tag_request( + user_api_key_dict=user_key, + requested_model="gpt-4o", + ) + + assert all(call.args[0] != "litellm.user_email" for call in mock_set_tag.call_args_list) + class TestHasAttributeErrorInChain: """Tests for _has_attribute_error_in_chain helper.""" diff --git a/tests/test_litellm/proxy/test_health_check_functions.py b/tests/test_litellm/proxy/test_health_check_functions.py index c0c853ae2c5f..7d6c5d3cebe5 100644 --- a/tests/test_litellm/proxy/test_health_check_functions.py +++ b/tests/test_litellm/proxy/test_health_check_functions.py @@ -374,7 +374,7 @@ async def test_save_background_health_checks_to_db(): """Test the main background health check save function""" mock_prisma = MagicMock() mock_prisma.save_health_check_result = AsyncMock() - mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) model_list = [ { @@ -398,9 +398,9 @@ async def test_save_background_health_checks_to_db(): "background_health_check", ) - # Should call get_all_latest_health_checks and save_health_check_result, and report completion + # Should read the latest rows and save_health_check_result, and report completion assert persisted is True - mock_prisma.get_all_latest_health_checks.assert_called_once() + mock_prisma.db.query_raw.assert_awaited_once() mock_prisma.save_health_check_result.assert_called_once() call_kwargs = mock_prisma.save_health_check_result.call_args[1] @@ -493,7 +493,7 @@ def _one_model_setup(): @pytest.mark.asyncio async def test_save_background_health_checks_to_db_returns_false_when_a_write_fails(): mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.save_health_check_result = AsyncMock(return_value=None) model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup() @@ -504,6 +504,23 @@ async def test_save_background_health_checks_to_db_returns_false_when_a_write_fa assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 1) +@pytest.mark.asyncio +async def test_save_background_health_checks_to_db_writes_nothing_when_the_latest_row_read_fails(mock_prisma): + """ + A failed dedup read must not read as an empty table. Treated that way, every model was written on every + cycle by every pod while the read kept failing, which is what filled the table in production. + """ + mock_prisma.db.query_raw = AsyncMock(side_effect=RuntimeError("db down")) + mock_prisma.save_health_check_result = AsyncMock(return_value={"id": "row"}) + model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup() + + persisted = await _save_background_health_checks_to_db( + mock_prisma, model_list, healthy_endpoints, unhealthy_endpoints, 1234567890.0, "background_health_check" + ) + + assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 0) + + @pytest.mark.asyncio async def test_save_background_health_checks_to_db_no_prisma(): """Test graceful handling when no prisma client""" @@ -515,7 +532,7 @@ async def test_save_background_health_checks_to_db_no_prisma(): async def test_save_background_health_checks_to_db_exception_handling(): """Test exception handling in background health check save""" mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock(side_effect=Exception("DB Error")) + mock_prisma.db.query_raw = AsyncMock(side_effect=Exception("DB Error")) model_list = [ { diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index b8a0d70f5bc7..8bc40cbee4e7 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1790,6 +1790,31 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost(): assert result.output_cost_per_token is None +def test_model_group_info_description_from_model_info(): + """ + model_info.description set in the config should surface on ModelGroupInfo, + including when only a later deployment in the group carries it. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "gpt-4", "api_key": "fake"}, + "model_info": {}, + }, + { + "model_name": "gpt-4", + "litellm_params": {"model": "gpt-4", "api_key": "fake2"}, + "model_info": {"description": "State-of-the-art language model."}, + }, + ] + ) + + result = router.get_model_group_info("gpt-4") + assert result is not None + assert result.description == "State-of-the-art language model." + + @pytest.mark.parametrize( "value,expected", [ diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 634adc6fba87..b0e26a13c323 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -660,6 +660,12 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, ))} + {selectedModel.description && ( +
+

Description:

+

{selectedModel.description}

+
+ )} diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx index 9f74771f3b19..b025bcf62aac 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx @@ -31,6 +31,7 @@ export interface ModelHubData { supports_function_calling: boolean; supported_openai_params?: string[]; is_public_model_group: boolean; + description?: string; [key: string]: any; } diff --git a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx index de9873607917..2fae8044d6b6 100644 --- a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx @@ -25,6 +25,7 @@ export interface ModelGroupInfo { health_status?: string; health_response_time?: number; health_checked_at?: string; + description?: string; [key: string]: any; } diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 29a84175d75e..5267e4287457 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -714,6 +714,12 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded })} + {selectedModel.description && ( +
+

Description:

+

{selectedModel.description}

+
+ )} {/* Wildcard Routing Note */} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a135ca242ad3..e1b818530bcd 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -32132,6 +32132,8 @@ export interface components { ModelGroupInfoProxy: { /** Configurable Clientside Auth Params */ configurable_clientside_auth_params?: (string | components["schemas"]["ConfigurableClientsideParamsCustomAuth-Output"])[] | null; + /** Description */ + description?: string | null; /** Health Checked At */ health_checked_at?: string | null; /** Health Response Time */