From c5da69cd131ee227d9a466cd068cddfa7afb0858 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 4 Nov 2026 18:50:14 -0800 Subject: [PATCH] feat(ui): show model deprecation banner on the models page (#50080) * feat(ui): show model deprecation banner on the models page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format model deprecation banner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): refresh model deprecations when models change Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): address review feedback on model deprecation banner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): cover deprecation role gate and agent builder refresh Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): separate deprecation banner toggle from alerts link Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): dedupe and allow dismissing the model deprecation banner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): refresh table once and key banner entries on successor Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: quiet successor logs, count banner models, use local dates Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): pin banner test date and bucket upcoming entries locally Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: cover mixed-successor log level through the deprecations endpoint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): move deprecation banner cases into integration tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep deprecation banner when a refetch fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Krrish Dholakia Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/common_utils/model_deprecation.py | 46 +- litellm/proxy/proxy_server.py | 7 +- litellm/types/proxy/model_deprecation.py | 4 + .../common_utils/test_model_deprecation.py | 50 ++ .../proxy/test_model_deprecations_endpoint.py | 78 ++- .../hooks/models/invalidateModelQueries.ts | 9 + .../models/useModelDeprecations.test.tsx | 64 +++ .../hooks/models/useModelDeprecations.ts | 33 ++ .../app/(dashboard)/hooks/models/useModels.ts | 5 +- .../AllModelsTab.integration.test.tsx | 232 +++++++++ .../components/AllModelsTab.test.tsx | 9 +- .../components/AllModelsTab.tsx | 11 +- .../ModelDeprecationBanner.test.tsx | 477 ++++++++++++++++++ .../components/ModelDeprecationBanner.tsx | 98 ++++ ...riceDataManagementTab.integration.test.tsx | 83 +++ .../PriceDataManagementTab.test.tsx | 8 +- .../components/PriceDataManagementTab.tsx | 6 +- .../components/deprecationBannerText.test.ts | 140 +++++ .../components/deprecationBannerText.ts | 96 ++++ .../page.integration.test.tsx | 55 +- .../models-and-endpoints/page.test.tsx | 9 + .../(dashboard)/models-and-endpoints/page.tsx | 7 +- .../panels/AddModelPanel.integration.test.tsx | 78 ++- .../panels/AddModelPanel.tsx | 3 +- .../chat_ui/AgentBuilderView.test.tsx | 97 +++- .../components/chat_ui/AgentBuilderView.tsx | 6 + .../model_info_view.integration.test.tsx | 79 ++- .../src/components/model_info_view.tsx | 3 +- .../src/components/settings.test.tsx | 27 +- .../src/components/settings.tsx | 13 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 + 31 files changed, 1785 insertions(+), 53 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/models/invalidateModelQueries.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.ts diff --git a/litellm/proxy/common_utils/model_deprecation.py b/litellm/proxy/common_utils/model_deprecation.py index b86ebd830f9..832ebe175b5 100644 --- a/litellm/proxy/common_utils/model_deprecation.py +++ b/litellm/proxy/common_utils/model_deprecation.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import date, datetime, timezone @@ -166,14 +167,20 @@ def _classify(days_until: int, warn_within_days: int) -> DeprecationStatus: return "upcoming" -def _checked_successor(model_name: str, model_info: Mapping[str, object], llm_router: Router) -> str | None: +def _checked_successor( + model_name: str, + model_info: Mapping[str, object], + llm_router: Router, + successor_log_level: int, +) -> str | None: """The deployment's successor_model as set, with a warning when no model in its team scope matches it""" successor: Final = _successor_model(model_info.get("successor_model")) if successor is None: return None team_id: Final = model_info.get("team_id") if not llm_router.registry().deployments(successor, team_id=team_id if isinstance(team_id, str) else None): - verbose_logger.warning( + verbose_logger.log( + successor_log_level, "model_deprecation: %s names successor %s, which matches no model group, alias, or wildcard route here", model_name, successor, @@ -187,6 +194,7 @@ def _build_info( today: date, warn_within_days: int, catalog: Mapping[str, object], + successor_log_level: int, ) -> _DeploymentInfo | None: model_name: Final = deployment.get("model_name") if not isinstance(model_name, str) or not model_name: @@ -197,10 +205,18 @@ def _build_info( return None days_until: Final = (resolved.deprecation_date - today).days - configured: Final = _checked_successor(model_name, _mapping_field(deployment, "model_info"), llm_router) + model_info: Final = _mapping_field(deployment, "model_info") + configured: Final = _checked_successor(model_name, model_info, llm_router, successor_log_level) + team_public_model_name_value: Final = model_info.get("team_public_model_name") + team_public_model_name: Final = ( + team_public_model_name_value + if isinstance(team_public_model_name_value, str) and team_public_model_name_value.strip() + else None + ) return _DeploymentInfo( info=ModelDeprecationInfo( model_name=model_name, + team_public_model_name=team_public_model_name, litellm_model=resolved.litellm_model, deprecation_date=resolved.deprecation_date, days_until_deprecation=days_until, @@ -218,7 +234,7 @@ class _DeploymentInfo: configured_successor: bool -def _merge_group(group: Sequence[_DeploymentInfo]) -> ModelDeprecationInfo: +def _merge_group(group: Sequence[_DeploymentInfo], successor_log_level: int) -> ModelDeprecationInfo: """Keep the group's first deployment in config order, naming the first successor any of its deployments names, a successor configured in model_info ahead of one the cost map supplies""" first: Final = group[0].info @@ -234,7 +250,8 @@ def _merge_group(group: Sequence[_DeploymentInfo]) -> ModelDeprecationInfo: if not successors: return first if len(frozenset(successors)) > 1: - verbose_logger.warning( + verbose_logger.log( + successor_log_level, "model_deprecation: %s names several successors %s, reporting %s", first.model_name, sorted(frozenset(successors)), @@ -245,11 +262,12 @@ def _merge_group(group: Sequence[_DeploymentInfo]) -> ModelDeprecationInfo: def _dedupe( models: Sequence[_DeploymentInfo], + successor_log_level: int, ) -> tuple[ModelDeprecationInfo, ...]: """Report a model group carrying the same date on several deployments once""" ordered: Final = sorted(models, key=lambda model: (model.info.model_name, model.info.deprecation_date)) return tuple( - _merge_group(tuple(group)) + _merge_group(tuple(group), successor_log_level) for _, group in groupby(ordered, key=lambda model: (model.info.model_name, model.info.deprecation_date)) ) @@ -264,11 +282,18 @@ def _bucket(models: Sequence[ModelDeprecationInfo], status: DeprecationStatus) - def _collect_from_router( - llm_router: Router, today: date, warn_within_days: int, catalog: Mapping[str, object] + llm_router: Router, + today: date, + warn_within_days: int, + catalog: Mapping[str, object], + successor_log_level: int, ) -> tuple[ModelDeprecationInfo, ...]: deployments: Final = llm_router.registry().deployments() or () - infos: Final = (_build_info(deployment, llm_router, today, warn_within_days, catalog) for deployment in deployments) - deduped: Final = _dedupe(tuple(info for info in infos if info is not None)) + infos: Final = ( + _build_info(deployment, llm_router, today, warn_within_days, catalog, successor_log_level) + for deployment in deployments + ) + deduped: Final = _dedupe(tuple(info for info in infos if info is not None), successor_log_level) verbose_logger.debug( "model_deprecation: %d/%d deployments carry a deprecation date", @@ -283,12 +308,13 @@ def collect_model_deprecations( warn_within_days: int = DEFAULT_DEPRECATION_WARN_DAYS, today: date | None = None, loaded_catalog: LoadedCatalog = GetModelCostMap.loaded_model_cost_map, + successor_log_level: int = logging.WARNING, ) -> ModelDeprecationResponse: """Bucket every deployment carrying a deprecation date by how urgent it is""" snapshot_time: Final = datetime.now(timezone.utc) effective_today: Final = today or snapshot_time.date() deduped: Final = ( - _collect_from_router(llm_router, effective_today, warn_within_days, loaded_catalog()) + _collect_from_router(llm_router, effective_today, warn_within_days, loaded_catalog(), successor_log_level) if llm_router is not None else () ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6a125b9a0a4..fddecb3d6eb 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6,6 +6,7 @@ import importlib import inspect import io import itertools +import logging import os import random import re @@ -18662,7 +18663,11 @@ async def model_deprecations( -H "Authorization: Bearer $LITELLM_MASTER_KEY" ``` """ - return collect_model_deprecations(llm_router=llm_router, warn_within_days=warn_within_days) + return collect_model_deprecations( + llm_router=llm_router, + warn_within_days=warn_within_days, + successor_log_level=logging.DEBUG, + ) def get_model_group_info( diff --git a/litellm/types/proxy/model_deprecation.py b/litellm/types/proxy/model_deprecation.py index 3699f01a684..7e459904d59 100644 --- a/litellm/types/proxy/model_deprecation.py +++ b/litellm/types/proxy/model_deprecation.py @@ -18,6 +18,10 @@ DeprecationStatus = Literal["upcoming", "imminent", "deprecated"] class ModelDeprecationInfo(LiteLLMBaseModel): model_name: str = Field(description="The public name of the model on the proxy (model_group).") + team_public_model_name: str | None = Field( + default=None, + description="The model name visible to the team, when configured.", + ) litellm_model: str | None = Field( default=None, description="The underlying litellm model string the deprecation date is sourced from.", diff --git a/tests/unit/proxy/common_utils/test_model_deprecation.py b/tests/unit/proxy/common_utils/test_model_deprecation.py index 7e96ad591b7..b3c135f1fad 100644 --- a/tests/unit/proxy/common_utils/test_model_deprecation.py +++ b/tests/unit/proxy/common_utils/test_model_deprecation.py @@ -467,6 +467,52 @@ class TestFormatDeprecationAlertMessage: class TestSuccessorModel: + def test_unmatched_successor_logs_warning_by_default(self, monkeypatch, caplog): + monkeypatch.setattr(litellm, "model_cost", {}) + router = Router( + model_list=[ + { + "model_name": "old", + "litellm_params": {"model": "openai/old-model"}, + "model_info": { + "id": "1", + "deprecation_date": "2026-06-10", + "successor_model": "missing-model", + }, + } + ] + ) + + with caplog.at_level(logging.DEBUG, logger=verbose_logger.name): + collect_model_deprecations(llm_router=router, today=date(2026, 6, 1)) + + successor_records: Final = tuple( + record for record in caplog.records if "names successor missing-model" in record.getMessage() + ) + assert len(successor_records) == 1 + assert successor_records[0].levelno == logging.WARNING + + def test_empty_team_public_model_name_is_normalized_to_none(self, monkeypatch): + monkeypatch.setattr(litellm, "model_cost", {}) + router = Router( + model_list=[ + { + "model_name": "team-model", + "litellm_params": {"model": "openai/team-model"}, + "model_info": { + "id": "1", + "team_id": "team-1", + "team_public_model_name": "", + "deprecation_date": "2026-06-10", + }, + } + ] + ) + + snapshot: Final = collect_model_deprecations(llm_router=router, today=date(2026, 6, 1)) + + assert snapshot.imminent[0].team_public_model_name is None + def test_should_surface_the_successor_set_on_the_deployment(self, monkeypatch): monkeypatch.setattr(litellm, "model_cost", {}) router = Router( @@ -572,6 +618,10 @@ class TestSuccessorModel: "typo-old": "gpt-nwe", "wildcard-old": "anthropic/x", } + team_deprecation: Final = next( + info for info in snapshot.imminent if info.model_name == "model_name_t1_old" + ) + assert team_deprecation.team_public_model_name == "team-old" warnings = [record.getMessage() for record in caplog.records if "names successor" in record.getMessage()] assert len(warnings) == 2 assert any("global-old names successor team-new" in warning for warning in warnings) diff --git a/tests/unit/proxy/test_model_deprecations_endpoint.py b/tests/unit/proxy/test_model_deprecations_endpoint.py index 7be90d0d1e4..705f5ec7b39 100644 --- a/tests/unit/proxy/test_model_deprecations_endpoint.py +++ b/tests/unit/proxy/test_model_deprecations_endpoint.py @@ -1,10 +1,12 @@ +import logging +from typing import Final from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient - import litellm +from litellm._logging import verbose_logger from litellm.proxy import proxy_server from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -140,3 +142,77 @@ def test_cost_map_successor_is_reported_unless_the_deployment_names_its_own(clie "dated-by-hand": "fresh-model", "orphan-alias": None, } + + +def test_unmatched_successor_logs_below_warning_through_endpoint(client_with_cost_map_successors, caplog, monkeypatch): + monkeypatch.setattr( + proxy_server.llm_router.get_model_list, + "side_effect", + lambda model_name=None, **kwargs: ( + proxy_server.llm_router.get_model_list.return_value if model_name is None else [] + ), + ) + + with caplog.at_level(logging.DEBUG, logger=verbose_logger.name): + response = client_with_cost_map_successors.get("/model/deprecations") + + assert response.status_code == 200, response.text + successor_records = tuple( + record + for record in caplog.records + if record.name == verbose_logger.name and "successor" in record.getMessage() + ) + assert successor_records + assert all(record.levelno < logging.WARNING for record in successor_records) + + +def test_mixed_successor_group_logs_below_warning_through_endpoint( + client_with_cost_map_successors: TestClient, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + router: Final = proxy_server.llm_router + models: Final[tuple[dict[str, object], ...]] = ( + { + "model_name": "mixed-successor-group", + "litellm_params": {"model": "retiring-model"}, + "model_info": {"id": "10", "successor_model": "successor-a"}, + }, + { + "model_name": "mixed-successor-group", + "litellm_params": {"model": "retiring-model"}, + "model_info": {"id": "11", "successor_model": "successor-b"}, + }, + { + "model_name": "successor-a", + "litellm_params": {"model": "fresh-model"}, + "model_info": {"id": "12"}, + }, + { + "model_name": "successor-b", + "litellm_params": {"model": "fresh-model"}, + "model_info": {"id": "13"}, + }, + ) + + def filtered_model_list( + model_name: str | None = None, **_kwargs: object + ) -> tuple[dict[str, object], ...]: + return models if model_name is None else tuple(model for model in models if model["model_name"] == model_name) + + monkeypatch.setattr(router.get_model_list, "side_effect", filtered_model_list) + + with caplog.at_level(logging.DEBUG, logger=verbose_logger.name): + response = client_with_cost_map_successors.get("/model/deprecations") + + assert response.status_code == 200, response.text + successor_records: Final = tuple( + record + for record in caplog.records + if record.name == verbose_logger.name and "successor" in record.getMessage() + ) + assert successor_records + assert all( + "names several successors" in record.getMessage() and record.levelno < logging.WARNING + for record in successor_records + ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/invalidateModelQueries.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/invalidateModelQueries.ts new file mode 100644 index 00000000000..3382d6c9c81 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/invalidateModelQueries.ts @@ -0,0 +1,9 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { modelDeprecationKeys } from "./useModelDeprecations"; + +export const invalidateModelQueries = async (queryClient: QueryClient): Promise => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["models", "list"] }), + queryClient.invalidateQueries({ queryKey: modelDeprecationKeys.all }), + ]); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.test.tsx new file mode 100644 index 00000000000..88efd721c26 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.test.tsx @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useModelDeprecations } from "./useModelDeprecations"; + +const authState = vi.hoisted(() => ({ accessToken: "token", userRole: "internal_user" })); +const emptyDeprecationsResponse = { + checked_at: "2026-11-04T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [], + upcoming: [], +}; +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: authState.accessToken, userRole: authState.userRole }), +})); + +describe("useModelDeprecations", () => { + let fetchMock: ReturnType>; + + beforeEach(() => { + authState.accessToken = "token"; + authState.userRole = "internal_user"; + fetchMock = vi.fn().mockResolvedValue(Response.json(emptyDeprecationsResponse)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not fetch deprecations for an internal user", async () => { + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useModelDeprecations(), { wrapper }); + + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("fetches deprecations for a proxy admin viewer", async () => { + authState.userRole = "proxy_admin_viewer"; + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + renderHook(() => useModelDeprecations(), { wrapper }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const request = fetchMock.mock.calls[0][0]; + expect(new URL(request instanceof Request ? request.url : String(request), "http://localhost").pathname).toBe( + "/model/deprecations", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.ts new file mode 100644 index 00000000000..bb9922d8a8b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelDeprecations.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { backgroundFetchClient } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { isProxyAdminTierRole } from "@/utils/roles"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +export type ModelDeprecationInfo = components["schemas"]["ModelDeprecationInfo"]; +export type ModelDeprecationResponse = components["schemas"]["ModelDeprecationResponse"]; + +export const modelDeprecationKeys = createQueryKeys("modelDeprecations"); + +const fetchModelDeprecations = async (): Promise => { + const { data } = await backgroundFetchClient.GET("/model/deprecations"); + if (data === undefined) { + throw new Error("The proxy returned no model deprecations payload"); + } + return data; +}; + +export const useModelDeprecations = () => { + const { accessToken, userRole } = useAuthorized(); + const queryOptions = { + queryKey: modelDeprecationKeys.all, + queryFn: fetchModelDeprecations, + enabled: Boolean(accessToken) && isProxyAdminTierRole(userRole), + staleTime: 5 * 60 * 1000, + retry: false, + }; + return useQuery(queryOptions); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index f9304bab0d2..7e9669e6ce5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -1,6 +1,7 @@ import { useQuery, useInfiniteQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"; import { useMemo } from "react"; import { createQueryKeys } from "../common/queryKeysFactory"; +import { invalidateModelQueries } from "./invalidateModelQueries"; import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking"; import useAuthorized from "../useAuthorized"; import { EndpointType, isModeCompatibleWithEndpoint } from "@/components/chat_ui/mode_endpoint_mapping"; @@ -240,9 +241,7 @@ export const useAutoRouters = (): UseQueryResult export const useInvalidateAutoRouters = (): (() => Promise) => { const queryClient = useQueryClient(); - return async () => { - await queryClient.invalidateQueries({ queryKey: modelKeys.lists() }); - }; + return () => invalidateModelQueries(queryClient); }; export const useModelHub = () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.integration.test.tsx new file mode 100644 index 00000000000..42747bfeeca --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.integration.test.tsx @@ -0,0 +1,232 @@ +import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; +import { useQuery } from "@tanstack/react-query"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils"; +import AllModelsTab from "./AllModelsTab"; +import ModelDeprecationBanner from "./ModelDeprecationBanner"; + +const mockModelDeleteCall = vi.fn().mockResolvedValue({}); +const mockModelPatchUpdateCall = vi.fn().mockResolvedValue({}); +vi.mock("@/components/networking", () => ({ + serverRootPath: "/", + modelDeleteCall: (...args: unknown[]) => mockModelDeleteCall(...args), + modelPatchUpdateCall: (...args: unknown[]) => mockModelPatchUpdateCall(...args), +})); + +vi.mock("@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal", () => ({ + default: function ModelSettingsModalMock({ isVisible }: { isVisible: boolean }) { + return isVisible ?
: null; + }, +})); + +let modelsInfoResult: Record = {}; +const mockRefetch = vi.fn(); +vi.mock("../../hooks/models/useModels", () => ({ + useModelsInfo: () => ({ ...modelsInfoResult, refetch: mockRefetch }), +})); + +vi.mock("../../hooks/models/useModelCostMap", () => ({ + useModelCostMap: () => ({ data: { "gpt-4": { litellm_provider: "openai" } }, isLoading: false, error: null }), +})); + +const mockTeams = [{ team_id: "team-1", team_alias: "Engineering" }]; +const mockCredentials = [ + { credential_name: "openai-prod", display_name: "Prod OpenAI", credential_values: {}, credential_info: {} }, +]; +const mockUseCredentials = vi.hoisted(() => vi.fn()); +vi.mock("../../hooks/credentials/useCredentials", () => ({ + useCredentials: mockUseCredentials, +})); + +vi.mock("../../hooks/teams/useTeams", () => ({ + useTeams: () => ({ data: mockTeams, isLoading: false, error: null, refetch: vi.fn() }), +})); + +const BASE_MODEL_INFO = { + id: "model-1", + db_model: true, + created_by: "user-123", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + team_id: "team-1", + access_groups: [], +}; + +const makeRow = () => ({ + model_name: "gpt-4", + litellm_params: { model: "openai/gpt-4", custom_llm_provider: "openai" }, + model_info: BASE_MODEL_INFO, +}); + +const setModelsInfo = (rows: Record[]) => { + modelsInfoResult = { + data: { data: rows, total_count: rows.length, current_page: 1, total_pages: 1, size: 50 }, + isLoading: false, + isFetching: false, + error: null, + }; +}; + +const MOCK_AUTHORIZED = { + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "mock-access-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Admin", + userRoleLabel: "Admin", + isViewOnly: false, + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + +const mockSetSelectedModelGroup = vi.fn(); +const mockSetSelectedModelId = vi.fn(); +const mockSetSelectedTeamId = vi.fn(); + +const defaultProps = { + selectedModelGroup: "all", + setSelectedModelGroup: mockSetSelectedModelGroup, + availableModelGroups: ["gpt-4", "gpt-3.5-turbo"], + availableModelAccessGroups: ["sales-team"], + setSelectedModelId: mockSetSelectedModelId, + setSelectedTeamId: mockSetSelectedTeamId, +}; + +const TEST_TODAY = new Date(2026, 10, 4); + +const deprecatedResponse = { + checked_at: "2026-06-01T00:00:00Z", + warn_within_days: 30, + deprecated: [ + { + model_name: "old-model", + litellm_provider: null, + deprecation_date: "2026-06-01", + days_until_deprecation: -1, + status: "deprecated", + successor_model: null, + }, + ], + imminent: [], + upcoming: [], +}; + +const currentResponse = { + checked_at: "2026-06-02T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [ + { + model_name: "current-model", + litellm_provider: null, + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + successor_model: null, + }, + ], + upcoming: [], +}; + +const stubDeprecationResponses = () => { + const fetchMock = vi + .fn() + .mockImplementationOnce(async (input) => { + const requestUrl = input instanceof Request ? input.url : String(input); + expect(new URL(requestUrl, "http://localhost").pathname).toBe("/model/deprecations"); + return Response.json(deprecatedResponse); + }) + .mockImplementationOnce(async (input) => { + const requestUrl = input instanceof Request ? input.url : String(input); + expect(new URL(requestUrl, "http://localhost").pathname).toBe("/model/deprecations"); + return Response.json(currentResponse); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +const renderModelsWithDeprecationBanner = () => + renderWithProviders( + <> + + + , + ); + +const ModelListQueryProbe = ({ queryFn }: { queryFn: () => Promise }) => { + useQuery({ queryKey: ["models", "list"], queryFn, staleTime: Infinity }); + return null; +}; + +describe("AllModelsTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + setModelsInfo([makeRow()]); + vi.spyOn(useAuthorizedModule, "default").mockReturnValue(MOCK_AUTHORIZED); + mockUseCredentials.mockImplementation(({ enabled = true }: { enabled?: boolean } = {}) => ({ + data: enabled ? { credentials: mockCredentials } : undefined, + isLoading: false, + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refreshes deprecation banner data after deleting a model", async () => { + const fetchMock = stubDeprecationResponses(); + const user = userEvent.setup(); + renderModelsWithDeprecationBanner(); + + expect(await screen.findByText("old-model was deprecated on 2026-06-01")).toBeInTheDocument(); + await user.click(await screen.findByTestId("model-delete-model-1")); + await user.click(await screen.findByRole("button", { name: /^delete$/i })); + expect(await screen.findByText("current-model deprecates on 2026-11-30, in 26 days")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("old-model was deprecated on 2026-06-01")).not.toBeInTheDocument(); + }); + + it("refreshes deprecation banner data after pausing a model", async () => { + const fetchMock = stubDeprecationResponses(); + const user = userEvent.setup(); + renderModelsWithDeprecationBanner(); + + expect(await screen.findByText("old-model was deprecated on 2026-06-01")).toBeInTheDocument(); + await user.click(await screen.findByTestId("model-pause-toggle-model-1")); + expect(await screen.findByText("current-model deprecates on 2026-11-30, in 26 days")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("old-model was deprecated on 2026-06-01")).not.toBeInTheDocument(); + }); + + it("refreshes the table and deprecations once without refetching another models list observer", async () => { + const fetchMock = stubDeprecationResponses(); + const observerQueryFn = vi.fn<() => Promise>().mockResolvedValue("models"); + const user = userEvent.setup(); + renderWithProviders( + <> + + + + , + ); + + expect(await screen.findByText("old-model was deprecated on 2026-06-01")).toBeInTheDocument(); + await waitFor(() => expect(observerQueryFn).toHaveBeenCalledTimes(1)); + mockRefetch.mockClear(); + fetchMock.mockClear(); + + await user.click(screen.getByTestId("datatable-refresh")); + + expect(await screen.findByText("current-model deprecates on 2026-11-30, in 26 days")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(observerQueryFn).toHaveBeenCalledTimes(1); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 09e00db9fb0..97e7f0238bb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; -import { renderWithProviders } from "../../../../../tests/test-utils"; +import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils"; import AllModelsTab from "./AllModelsTab"; import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns"; @@ -22,12 +22,6 @@ vi.mock("@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal", () }, })); -const mockInvalidateQueries = vi.fn(); -vi.mock("@tanstack/react-query", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }; -}); - interface ModelsInfoArgs { page?: number; size?: number; @@ -157,6 +151,7 @@ const defaultProps = { describe("AllModelsTab", () => { beforeEach(() => { vi.clearAllMocks(); + testQueryClient.clear(); modelsInfoCalls.length = 0; setModelsInfo([makeRow()]); vi.spyOn(useAuthorizedModule, "default").mockReturnValue(MOCK_AUTHORIZED); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index ff027ac8b3c..cf977a6d9e3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -22,6 +22,8 @@ import { useCallback, useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useModelsInfo } from "../../hooks/models/useModels"; +import { modelDeprecationKeys } from "../../hooks/models/useModelDeprecations"; +import { invalidateModelQueries } from "../../hooks/models/invalidateModelQueries"; import { ROUTING_STATUS_FILTER_ID, routingStatusToBlocked } from "../utils/routingStatus"; import { transformModelData } from "../utils/modelDataTransformer"; import { @@ -266,8 +268,8 @@ const AllModelsTab = ({ try { setDeleteLoading(true); await modelDeleteCall(accessToken, deleteModalModelId); + void invalidateModelQueries(queryClient); toast.success(t("modelsAndEndpoints.allModelsTab.toasts.deleted")); - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); refetchModels(); } catch (error) { console.error("Error deleting model:", error); @@ -291,7 +293,7 @@ const AllModelsTab = ({ ); // invalidateQueries already schedules a refetch for active observers // on this key — no need to also call refetchModels() (would double-fetch). - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + void invalidateModelQueries(queryClient); } catch (error) { console.error("Error toggling model pause state:", error); toast.fromError(error); @@ -303,8 +305,9 @@ const AllModelsTab = ({ ); const handleRefresh = useCallback(() => { - void refetchModels(); - }, [refetchModels]); + refetchModels(); + void queryClient.invalidateQueries({ queryKey: modelDeprecationKeys.all }); + }, [queryClient, refetchModels]); const handleDeleteClick = useCallback((modelId: string) => { setDeleteModalModelId(modelId); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.test.tsx new file mode 100644 index 00000000000..c14bf93c116 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.test.tsx @@ -0,0 +1,477 @@ +/* @vitest-environment jsdom */ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + useModelDeprecations, + type ModelDeprecationInfo, + type ModelDeprecationResponse, +} from "@/app/(dashboard)/hooks/models/useModelDeprecations"; +import { uiHref } from "@/utils/uiHref"; +import ModelDeprecationBanner from "./ModelDeprecationBanner"; + +vi.mock("@/app/(dashboard)/hooks/models/useModelDeprecations", () => ({ + useModelDeprecations: vi.fn(), +})); + +const mockedUseModelDeprecations = vi.mocked(useModelDeprecations); + +const makeResponse = (overrides: Partial = {}): ModelDeprecationResponse => ({ + checked_at: "2026-11-04T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [], + upcoming: [], + ...overrides, +}); + +const makeInfo = (overrides: Partial = {}): ModelDeprecationInfo => ({ + model_name: "gpt-4o", + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + ...overrides, +}); + +const queryResult = ( + data: ModelDeprecationResponse | undefined, + isLoading = false, + isError = false, + overrides: Partial> = {}, +): ReturnType => + ({ data, isLoading, isError, ...overrides }) as ReturnType; + +const pinnedToday = (): Date => new Date(2026, 10, 4); + +const renderBanner = (today = pinnedToday()) => render(); + +describe("ModelDeprecationBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it("renders nothing when there are no deprecated or imminent models", () => { + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse())); + + renderBanner(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders nothing when only upcoming models are present", () => { + mockedUseModelDeprecations.mockReturnValue( + queryResult( + makeResponse({ + upcoming: [makeInfo({ deprecation_date: "2026-12-05", status: "upcoming" })], + }), + ), + ); + + renderBanner(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("includes server-upcoming models at the local warning-window boundary", () => { + const upcomingInfoOverrides: Partial = { + model_name: "due-in-window", + deprecation_date: "2026-12-04", + days_until_deprecation: 31, + status: "upcoming", + }; + const info = makeInfo(upcomingInfoOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ upcoming: [info] }))); + + renderBanner(); + + expect(screen.getByText("1 model deprecates within 30 days")).toBeInTheDocument(); + expect(within(screen.getByRole("list")).getByRole("listitem")).toHaveTextContent( + "due-in-window deprecates on 2026-12-04, in 30 days", + ); + }); + + it("does not show server-upcoming models one day past the local warning window", () => { + const upcomingInfoOverrides: Partial = { + model_name: "due-outside-window", + deprecation_date: "2026-12-05", + days_until_deprecation: 31, + status: "upcoming", + }; + const info = makeInfo(upcomingInfoOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ upcoming: [info] }))); + + renderBanner(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders nothing while loading or after an error", () => { + mockedUseModelDeprecations.mockReturnValue(queryResult(undefined, true)); + const { rerender } = renderBanner(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + mockedUseModelDeprecations.mockReturnValue(queryResult(undefined, false, true)); + rerender(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the banner visible when a refetch fails", () => { + const staleInfoOverrides: Partial = { + model_name: "stale-model", + deprecation_date: "2026-06-01", + days_until_deprecation: -156, + status: "deprecated", + }; + const info = makeInfo(staleInfoOverrides); + const refetchError = new Error("Refetch failed"); + const refetchErrorResult: Partial> = { + error: refetchError, + failureCount: 1, + failureReason: refetchError, + fetchStatus: "idle", + isFetching: false, + isLoadingError: false, + isRefetchError: true, + status: "error", + }; + mockedUseModelDeprecations.mockReturnValue( + queryResult(makeResponse({ deprecated: [info] }), false, true, refetchErrorResult), + ); + + renderBanner(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(within(screen.getByRole("list")).getByRole("listitem")).toHaveTextContent( + "stale-model was deprecated on 2026-06-01", + ); + }); + + it("shows deprecated and imminent entries with a link to Logging & Alerts", () => { + const deprecatedInfo: ModelDeprecationInfo = { + model_name: "gpt-4", + team_public_model_name: null, + litellm_provider: "openai", + deprecation_date: "2026-06-01", + days_until_deprecation: -152, + status: "deprecated", + successor_model: "gpt-5.6-sol", + }; + const imminentInfo: ModelDeprecationInfo = { + model_name: "gpt-4o", + team_public_model_name: null, + litellm_provider: "openai", + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + successor_model: "X", + }; + const response = makeResponse({ + deprecated: [deprecatedInfo], + imminent: [imminentInfo], + }); + mockedUseModelDeprecations.mockReturnValue(queryResult(response)); + + renderBanner(); + + expect(screen.getByText("1 model is deprecated, 1 deprecates within 30 days")).toBeInTheDocument(); + const entries = within(screen.getByRole("list")).getAllByRole("listitem"); + expect(entries[0]).toHaveTextContent("gpt-4 (openai) was deprecated on 2026-06-01, migrate to gpt-5.6-sol"); + expect(entries[1]).toHaveTextContent("gpt-4o (openai) deprecates on 2026-11-30, in 26 days, migrate to X"); + expect(screen.getByRole("link", { name: "Get Slack alerts for deprecations" })).toHaveAttribute( + "href", + `${uiHref("logging-and-alerts")}?tab=alerting-types`, + ); + }); + + it("treats a server-deprecated entry dated today as imminent locally", () => { + const infoOverrides: Partial = { + model_name: "due-today", + deprecation_date: "2026-11-04", + days_until_deprecation: -1, + status: "deprecated", + }; + const info = makeInfo(infoOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ deprecated: [info] }))); + + renderBanner(); + + expect(screen.getByText("1 model deprecates within 30 days")).toBeInTheDocument(); + expect(within(screen.getByRole("list")).getByRole("listitem")).toHaveTextContent( + "due-today deprecates today (2026-11-04)", + ); + }); + + it("treats a server-imminent entry dated yesterday as deprecated locally", () => { + const infoOverrides: Partial = { + model_name: "due-yesterday", + deprecation_date: "2026-11-03", + days_until_deprecation: 0, + status: "imminent", + }; + const info = makeInfo(infoOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ imminent: [info] }))); + + renderBanner(); + + expect(screen.getByText("1 model is deprecated")).toBeInTheDocument(); + expect(within(screen.getByRole("list")).getByRole("listitem")).toHaveTextContent( + "due-yesterday was deprecated on 2026-11-03", + ); + }); + + it("uses the local date difference instead of the server day count", () => { + const infoOverrides: Partial = { + model_name: "due-in-two", + deprecation_date: "2026-11-06", + days_until_deprecation: 1, + status: "imminent", + }; + const info = makeInfo(infoOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ imminent: [info] }))); + + renderBanner(); + + expect(within(screen.getByRole("list")).getByRole("listitem")).toHaveTextContent( + "due-in-two deprecates on 2026-11-06, in 2 days", + ); + }); + + it("deduplicates entries by their displayed name, provider, and deprecation date", () => { + const globalInfoOverrides: Partial = { + model_name: "o4-mini", + team_public_model_name: null, + litellm_provider: "openai", + deprecation_date: "2026-10-23", + status: "deprecated", + }; + const firstTeamInfoOverrides: Partial = { + model_name: "o4-mini-team-a", + team_public_model_name: "o4-mini", + litellm_provider: "openai", + deprecation_date: "2026-10-23", + status: "deprecated", + }; + const secondTeamInfoOverrides: Partial = { + model_name: "o4-mini-team-b", + team_public_model_name: "o4-mini", + litellm_provider: "openai", + deprecation_date: "2026-10-23", + status: "deprecated", + }; + mockedUseModelDeprecations.mockReturnValue( + queryResult( + makeResponse({ + deprecated: [ + makeInfo(globalInfoOverrides), + makeInfo(firstTeamInfoOverrides), + makeInfo(secondTeamInfoOverrides), + ], + }), + ), + ); + + renderBanner(); + + expect(screen.getByText("1 model is deprecated")).toBeInTheDocument(); + const entries = within(screen.getByRole("list")).getAllByRole("listitem"); + expect(entries).toHaveLength(1); + expect(entries[0]).toHaveTextContent("o4-mini (openai) was deprecated on 2026-10-23"); + }); + + it("keeps entries with different successors and renders each migration target", () => { + const firstEntryOverrides: Partial = { + model_name: "team-model-a", + team_public_model_name: "shared-model", + litellm_provider: "openai", + deprecation_date: "2026-10-23", + days_until_deprecation: -1, + status: "deprecated", + successor_model: "successor-a", + }; + const secondEntryOverrides: Partial = { + model_name: "shared-model", + litellm_provider: "openai", + deprecation_date: "2026-10-23", + days_until_deprecation: -1, + status: "deprecated", + successor_model: "successor-b", + }; + const firstEntry = makeInfo(firstEntryOverrides); + const secondEntry = makeInfo(secondEntryOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ deprecated: [firstEntry, secondEntry] }))); + + renderBanner(); + + const entries = within(screen.getByRole("list")).getAllByRole("listitem"); + expect(screen.getByText("1 model is deprecated")).toBeInTheDocument(); + expect(entries).toHaveLength(2); + expect(entries[0]).toHaveTextContent("shared-model (openai) was deprecated on 2026-10-23, migrate to successor-a"); + expect(entries[1]).toHaveTextContent("shared-model (openai) was deprecated on 2026-10-23, migrate to successor-b"); + }); + + it("keeps entries with different deprecation dates", () => { + const firstEntryOverrides: Partial = { + model_name: "shared-model", + litellm_provider: "openai", + deprecation_date: "2026-10-22", + days_until_deprecation: -2, + status: "deprecated", + successor_model: "successor", + }; + const secondEntryOverrides: Partial = { + model_name: "shared-model", + litellm_provider: "openai", + deprecation_date: "2026-10-23", + days_until_deprecation: -1, + status: "deprecated", + successor_model: "successor", + }; + const firstEntry = makeInfo(firstEntryOverrides); + const secondEntry = makeInfo(secondEntryOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ deprecated: [firstEntry, secondEntry] }))); + + renderBanner(); + + const entries = within(screen.getByRole("list")).getAllByRole("listitem"); + expect(entries).toHaveLength(2); + expect(entries[0]).toHaveTextContent("shared-model (openai) was deprecated on 2026-10-22"); + expect(entries[1]).toHaveTextContent("shared-model (openai) was deprecated on 2026-10-23"); + }); + + it("dismisses the banner and keeps it dismissed after remounting the same entries", async () => { + const info = makeInfo(); + const secondInfo = makeInfo({ model_name: "claude-sonnet-4" }); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ imminent: [info, secondInfo] }))); + const user = userEvent.setup(); + const { unmount } = renderBanner(); + + await user.click(screen.getByRole("button", { name: "Dismiss deprecation warning" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + unmount(); + + const changedSecondInfoOverrides: Partial = { + ...secondInfo, + days_until_deprecation: secondInfo.days_until_deprecation - 1, + }; + const changedInfoOverrides: Partial = { + ...info, + days_until_deprecation: info.days_until_deprecation - 1, + }; + const teamAliasInfoOverrides: Partial = { + ...info, + model_name: "team-internal-name", + team_public_model_name: info.model_name, + days_until_deprecation: info.days_until_deprecation - 1, + }; + mockedUseModelDeprecations.mockReturnValue( + queryResult( + makeResponse({ + imminent: [ + makeInfo(changedSecondInfoOverrides), + makeInfo(changedInfoOverrides), + makeInfo(teamAliasInfoOverrides), + ], + }), + ), + ); + renderBanner(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("shows the banner again when a new entry is added after dismissal", async () => { + const firstEntryOverrides: Partial = { + model_name: "gpt-4", + deprecation_date: "2026-10-23", + days_until_deprecation: -12, + status: "deprecated", + }; + const firstEntry = makeInfo(firstEntryOverrides); + const addedEntryOverrides: Partial = { + model_name: "gpt-3.5", + deprecation_date: "2026-10-23", + days_until_deprecation: -12, + status: "deprecated", + }; + const addedEntry = makeInfo(addedEntryOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ deprecated: [firstEntry] }))); + const user = userEvent.setup(); + const { rerender } = renderBanner(); + + await user.click(screen.getByRole("button", { name: "Dismiss deprecation warning" })); + mockedUseModelDeprecations.mockReturnValue( + queryResult( + makeResponse({ + deprecated: [firstEntry, addedEntry], + }), + ), + ); + rerender(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("2 models are deprecated")).toBeInTheDocument(); + }); + + it("shows the banner again when an imminent entry becomes deprecated", async () => { + const imminentEntryOverrides: Partial = { + model_name: "gpt-4o", + deprecation_date: "2026-11-30", + days_until_deprecation: 0, + status: "imminent", + }; + const imminentEntry = makeInfo(imminentEntryOverrides); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ imminent: [imminentEntry] }))); + const user = userEvent.setup(); + const { rerender } = renderBanner(new Date(2026, 10, 30)); + + await user.click(screen.getByRole("button", { name: "Dismiss deprecation warning" })); + rerender(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("1 model is deprecated")).toBeInTheDocument(); + }); + + it("shows the banner again when an entry's successor changes after dismissal", async () => { + const entry = makeInfo({ successor_model: "successor-a" }); + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ imminent: [entry] }))); + const user = userEvent.setup(); + const { rerender } = renderBanner(); + + await user.click(screen.getByRole("button", { name: "Dismiss deprecation warning" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + mockedUseModelDeprecations.mockReturnValue( + queryResult(makeResponse({ imminent: [makeInfo({ ...entry, successor_model: "successor-b" })] })), + ); + rerender(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/migrate to successor-b/)).toBeInTheDocument(); + }); + + it("expands all entries and collapses back to the first five", async () => { + const imminent = [ + ...Array.from({ length: 7 }, (_value, index) => makeInfo({ model_name: `model-${index + 1}` })), + makeInfo({ model_name: "team-model-1", team_public_model_name: "model-1" }), + ]; + mockedUseModelDeprecations.mockReturnValue(queryResult(makeResponse({ imminent }))); + const user = userEvent.setup(); + + renderBanner(); + + const entries = within(screen.getByRole("list")).getAllByRole("listitem"); + expect(entries).toHaveLength(5); + expect(entries[4]).toHaveTextContent("model-5 deprecates"); + expect(screen.queryByText(/model-6/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show all 7" })); + expect(within(screen.getByRole("list")).getAllByRole("listitem")).toHaveLength(7); + expect(screen.getByText(/model-6 deprecates/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show fewer" })); + expect(within(screen.getByRole("list")).getAllByRole("listitem")).toHaveLength(5); + expect(screen.queryByText(/model-6/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.tsx new file mode 100644 index 00000000000..3a89940f786 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { TriangleAlert, X } from "lucide-react"; +import { useState } from "react"; +import { useModelDeprecations } from "@/app/(dashboard)/hooks/models/useModelDeprecations"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils"; +import { uiHref } from "@/utils/uiHref"; +import { + bucketByLocalDate, + countDistinctModels, + dedupeDeprecationEntries, + deprecationBannerFingerprint, + deprecationBannerTitle, + deprecationEntryText, +} from "./deprecationBannerText"; + +const STORAGE_KEY = "modelDeprecationBannerDismissed"; + +const ModelDeprecationBanner = ({ today = new Date() }: { today?: Date }) => { + const { data } = useModelDeprecations(); + const [isExpanded, setIsExpanded] = useState(false); + const [dismissedFingerprint, setDismissedFingerprint] = useState(() => + getLocalStorageItem(STORAGE_KEY), + ); + + if (!data) { + return null; + } + + const localBuckets = bucketByLocalDate( + [...(data.deprecated ?? []), ...(data.imminent ?? []), ...(data.upcoming ?? [])], + today, + data.warn_within_days, + ); + const deprecated = dedupeDeprecationEntries(localBuckets.deprecated); + const imminent = dedupeDeprecationEntries(localBuckets.imminent); + + if (deprecated.length === 0 && imminent.length === 0) { + return null; + } + + const entries = [...deprecated, ...imminent]; + const fingerprint = deprecationBannerFingerprint(entries); + if (dismissedFingerprint === fingerprint) { + return null; + } + + const visibleEntries = isExpanded ? entries : entries.slice(0, 5); + + return ( + 0 ? "destructive" : "default"} className="mb-4"> + + + + + + {deprecationBannerTitle(countDistinctModels(deprecated), countDistinctModels(imminent), data.warn_within_days)} + + +
    + {visibleEntries.map((info, index) => ( +
  • {deprecationEntryText(info)}
  • + ))} +
+
+ {entries.length > 5 && ( + + )} + + Get Slack alerts for deprecations + +
+
+
+ ); +}; + +export default ModelDeprecationBanner; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.integration.test.tsx new file mode 100644 index 00000000000..d2ddb805a0b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.integration.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import ModelDeprecationBanner from "./ModelDeprecationBanner"; +import PriceDataManagementTab from "./PriceDataManagementTab"; + +const TEST_TODAY = new Date(2026, 10, 4); + +vi.mock("@/components/price_data_reload", () => ({ + default: ({ onReloadSuccess }: { onReloadSuccess: () => void }) => , +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userRole: "Admin" }), +})); + +const renderTabWithBanner = (queryClient: QueryClient) => + render( + + <> + + + + , + ); + +describe("PriceDataManagementTab", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refetches deprecations after reloading price data", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const deprecatedResponse = { + checked_at: "2026-06-01T00:00:00Z", + warn_within_days: 30, + deprecated: [ + { + model_name: "old-model", + litellm_provider: null, + deprecation_date: "2026-06-01", + days_until_deprecation: -1, + status: "deprecated", + successor_model: null, + }, + ], + imminent: [], + upcoming: [], + }; + const currentResponse = { + checked_at: "2026-06-02T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [ + { + model_name: "current-model", + litellm_provider: null, + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + successor_model: null, + }, + ], + upcoming: [], + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(deprecatedResponse)) + .mockResolvedValueOnce(Response.json(currentResponse)); + vi.stubGlobal("fetch", fetchMock); + renderTabWithBanner(queryClient); + + expect(await screen.findByText("old-model was deprecated on 2026-06-01")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "reload" })); + expect(await screen.findByText("current-model deprecates on 2026-11-30, in 26 days")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect( + fetchMock.mock.calls.map( + ([input]) => new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname, + ), + ).toEqual(["/model/deprecations", "/model/deprecations"]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx index b2beb5beeed..445dc505ce6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx @@ -2,13 +2,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { modelDeprecationKeys } from "../../hooks/models/useModelDeprecations"; import { modelCostMapKeys } from "../../hooks/models/useModelCostMap"; import PriceDataManagementTab from "./PriceDataManagementTab"; vi.mock("@/components/price_data_reload", () => ({ default: ({ onReloadSuccess }: { onReloadSuccess: () => void }) => , })); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "sk-test", userRole: "Admin" }), +})); const renderTab = (queryClient: QueryClient) => render( @@ -27,13 +30,16 @@ describe("PriceDataManagementTab", () => { const queryClient = new QueryClient(); const liveKey = modelCostMapKeys.list({}); const catalogKey = modelCostMapKeys.list({ filters: { catalog_only: "true" } }); + const deprecationKey = modelDeprecationKeys.all; queryClient.setQueryData(liveKey, {}); queryClient.setQueryData(catalogKey, {}); + queryClient.setQueryData(deprecationKey, {}); renderTab(queryClient); fireEvent.click(screen.getByRole("button", { name: "reload" })); expect(queryClient.getQueryState(liveKey)?.isInvalidated).toBe(true); expect(queryClient.getQueryState(catalogKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(deprecationKey)?.isInvalidated).toBe(true); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx index 1457b5b311f..4dddc5333ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useQueryClient } from "@tanstack/react-query"; +import { modelDeprecationKeys } from "../../hooks/models/useModelDeprecations"; import { modelCostMapKeys } from "../../hooks/models/useModelCostMap"; const PriceDataManagementTab = () => { @@ -20,7 +21,10 @@ const PriceDataManagementTab = () => { { - queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all }); + void Promise.all([ + queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all }), + queryClient.invalidateQueries({ queryKey: modelDeprecationKeys.all }), + ]); }} buttonText={t("modelsAndEndpoints.priceDataManagementTab.reloadButton")} size="middle" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.test.ts new file mode 100644 index 00000000000..66d065e6caa --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { ModelDeprecationInfo } from "@/app/(dashboard)/hooks/models/useModelDeprecations"; +import { + bucketByLocalDate, + countDistinctModels, + deprecationBannerTitle, + deprecationEntryText, + localDaysUntil, + parseLocalDate, +} from "./deprecationBannerText"; + +const makeInfo = (overrides: Partial = {}): ModelDeprecationInfo => ({ + model_name: "gpt-4o", + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + ...overrides, +}); + +describe("local deprecation dates", () => { + it("parses date parts as a local calendar date and counts from local midnight", () => { + const parsed = parseLocalDate("2026-11-06"); + + expect([parsed.getFullYear(), parsed.getMonth(), parsed.getDate(), parsed.getHours()]).toEqual([2026, 10, 6, 0]); + expect(localDaysUntil("2026-11-06", new Date(2026, 10, 4, 18))).toBe(2); + }); + + it("buckets entries by their local date and warning window", () => { + const buckets = bucketByLocalDate( + [ + makeInfo({ model_name: "yesterday", deprecation_date: "2026-11-03", status: "imminent" }), + makeInfo({ model_name: "today", deprecation_date: "2026-11-04", status: "deprecated" }), + makeInfo({ model_name: "outside-window", deprecation_date: "2026-12-05", status: "imminent" }), + ], + new Date(2026, 10, 4), + 30, + ); + + expect( + buckets.deprecated.map(({ model_name, days_until_deprecation, status }) => [ + model_name, + days_until_deprecation, + status, + ]), + ).toEqual([["yesterday", -1, "deprecated"]]); + expect( + buckets.imminent.map(({ model_name, days_until_deprecation, status }) => [ + model_name, + days_until_deprecation, + status, + ]), + ).toEqual([["today", 0, "imminent"]]); + }); +}); + +describe("countDistinctModels", () => { + it("counts visible names and providers, not deployment aliases", () => { + expect( + countDistinctModels([ + makeInfo({ model_name: "team-a", team_public_model_name: "shared", litellm_provider: "openai" }), + makeInfo({ model_name: "team-b", team_public_model_name: "shared", litellm_provider: "openai" }), + makeInfo({ model_name: "team-c", team_public_model_name: "shared", litellm_provider: "anthropic" }), + ]), + ).toBe(2); + }); +}); + +describe("deprecationBannerTitle", () => { + it("formats both counts with singular and plural wording", () => { + expect(deprecationBannerTitle(2, 1, 30)).toBe("2 models are deprecated, 1 deprecates within 30 days"); + expect(deprecationBannerTitle(1, 2, 30)).toBe("1 model is deprecated, 2 deprecate within 30 days"); + }); + + it("omits empty counts and uses singular model wording", () => { + expect(deprecationBannerTitle(1, 0, 30)).toBe("1 model is deprecated"); + expect(deprecationBannerTitle(0, 1, 30)).toBe("1 model deprecates within 30 days"); + expect(deprecationBannerTitle(0, 3, 30)).toBe("3 models deprecate within 30 days"); + expect(deprecationBannerTitle(0, 0, 30)).toBe(""); + }); +}); + +describe("deprecationEntryText", () => { + it("formats deprecated entries with provider and successor", () => { + const deprecatedInfo: ModelDeprecationInfo = { + model_name: "gpt-4", + litellm_provider: "openai", + deprecation_date: "2026-06-01", + days_until_deprecation: -152, + status: "deprecated", + successor_model: "gpt-5.6-sol", + }; + + expect(deprecationEntryText(deprecatedInfo)).toBe( + "gpt-4 (openai) was deprecated on 2026-06-01, migrate to gpt-5.6-sol", + ); + }); + + it("omits a null provider and successor", () => { + expect( + deprecationEntryText( + makeInfo({ + litellm_provider: null, + successor_model: null, + }), + ), + ).toBe("gpt-4o deprecates on 2026-11-30, in 26 days"); + }); + + it("uses singular wording for one day", () => { + expect( + deprecationEntryText( + makeInfo({ + days_until_deprecation: 1, + }), + ), + ).toBe("gpt-4o deprecates on 2026-11-30, in 1 day"); + }); + + it("says an imminent model deprecates today", () => { + expect( + deprecationEntryText( + makeInfo({ + days_until_deprecation: 0, + deprecation_date: "2026-11-04", + }), + ), + ).toBe("gpt-4o deprecates today (2026-11-04)"); + }); + + it("uses the team-visible model name when present", () => { + expect( + deprecationEntryText( + makeInfo({ + model_name: "internal-name", + team_public_model_name: "team-name", + }), + ), + ).toBe("team-name deprecates on 2026-11-30, in 26 days"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.ts new file mode 100644 index 00000000000..0c035c5bdd6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/deprecationBannerText.ts @@ -0,0 +1,96 @@ +import type { ModelDeprecationInfo } from "@/app/(dashboard)/hooks/models/useModelDeprecations"; + +const shownModelName = (info: ModelDeprecationInfo): string => info.team_public_model_name ?? info.model_name; + +export const countDistinctModels = (infos: readonly ModelDeprecationInfo[]): number => + new Set(infos.map((info) => JSON.stringify([shownModelName(info), info.litellm_provider ?? null]))).size; + +export const parseLocalDate = (value: string): Date => { + const [year, month, day] = value.split("-"); + return new Date(Number(year), Number(month) - 1, Number(day)); +}; + +const startOfLocalDay = (today: Date): Date => new Date(today.getFullYear(), today.getMonth(), today.getDate()); + +export const localDaysUntil = (deprecationDate: string, today: Date): number => + Math.round((parseLocalDate(deprecationDate).getTime() - startOfLocalDay(today).getTime()) / 86_400_000); + +export const bucketByLocalDate = ( + infos: readonly ModelDeprecationInfo[], + today: Date, + warnWithinDays: number, +): { deprecated: ModelDeprecationInfo[]; imminent: ModelDeprecationInfo[] } => { + const inWindow = infos + .map((info): ModelDeprecationInfo => { + const daysUntilDeprecation = localDaysUntil(info.deprecation_date, today); + return { + ...info, + days_until_deprecation: daysUntilDeprecation, + status: daysUntilDeprecation < 0 ? "deprecated" : "imminent", + }; + }) + .filter((info) => info.days_until_deprecation <= warnWithinDays); + + return { + deprecated: inWindow.filter((info) => info.status === "deprecated"), + imminent: inWindow.filter((info) => info.status === "imminent"), + }; +}; + +const hasSameDedupeKey = (candidate: ModelDeprecationInfo, info: ModelDeprecationInfo): boolean => { + const sameDisplayedModel = shownModelName(candidate) === shownModelName(info); + const sameProvider = (candidate.litellm_provider ?? null) === (info.litellm_provider ?? null); + const sameDeprecationDate = candidate.deprecation_date === info.deprecation_date; + const sameSuccessor = (candidate.successor_model ?? null) === (info.successor_model ?? null); + const sameVisibleIdentity = sameDisplayedModel && sameProvider; + const sameDeprecationIdentity = sameDeprecationDate && sameSuccessor; + return sameVisibleIdentity && sameDeprecationIdentity; +}; + +export const dedupeDeprecationEntries = (infos: readonly ModelDeprecationInfo[]): ModelDeprecationInfo[] => + infos.filter((info, index) => infos.findIndex((candidate) => hasSameDedupeKey(candidate, info)) === index); + +export const deprecationBannerFingerprint = (infos: readonly ModelDeprecationInfo[]): string => + JSON.stringify( + infos + .map((info) => + JSON.stringify([ + shownModelName(info), + info.litellm_provider ?? null, + info.deprecation_date, + info.status, + info.successor_model ?? null, + ]), + ) + .sort(), + ); + +export const deprecationBannerTitle = ( + deprecatedCount: number, + imminentCount: number, + warnWithinDays: number, +): string => { + const deprecatedText = + deprecatedCount === 0 ? "" : `${deprecatedCount} ${deprecatedCount === 1 ? "model is" : "models are"} deprecated`; + const imminentModelLabel = imminentCount === 1 ? "model" : "models"; + const imminentSubject = deprecatedCount > 0 ? `${imminentCount}` : `${imminentCount} ${imminentModelLabel}`; + const imminentVerb = imminentCount === 1 ? "deprecates" : "deprecate"; + const imminentText = imminentCount === 0 ? "" : `${imminentSubject} ${imminentVerb} within ${warnWithinDays} days`; + return [deprecatedText, imminentText].filter((text) => text.length > 0).join(", "); +}; + +export const deprecationEntryText = (info: ModelDeprecationInfo): string => { + const model = `${shownModelName(info)}${info.litellm_provider ? ` (${info.litellm_provider})` : ""}`; + const successor = info.successor_model ? `, migrate to ${info.successor_model}` : ""; + + if (info.status === "deprecated") { + return `${model} was deprecated on ${info.deprecation_date}${successor}`; + } + + if (info.days_until_deprecation === 0) { + return `${model} deprecates today (${info.deprecation_date})${successor}`; + } + + const dayLabel = info.days_until_deprecation === 1 ? "day" : "days"; + return `${model} deprecates on ${info.deprecation_date}, in ${info.days_until_deprecation} ${dayLabel}${successor}`; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx index 4d18966625e..7aeff6f44f2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx @@ -1,9 +1,9 @@ /* @vitest-environment jsdom */ -import { screen } from "@testing-library/react"; +import { screen, waitForElementToBeRemoved } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as networking from "@/components/networking"; -import { renderWithProviders } from "../../../../tests/test-utils"; +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; import ModelsAndEndpointsPage from "./page"; vi.mock("./panels/AllModelsPanel", () => ({ default: () =>
})); @@ -201,6 +201,7 @@ const createMockTeamData = (overrides = {}) => ({ describe("ModelsAndEndpointsPage ?team drill-in", () => { beforeEach(() => { + testQueryClient.clear(); authState.userRole = "Admin"; authState.isViewOnly = false; can.mockReturnValue(true); @@ -221,6 +222,7 @@ describe("ModelsAndEndpointsPage ?team drill-in", () => { }); afterEach(() => { + vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -244,4 +246,53 @@ describe("ModelsAndEndpointsPage ?team drill-in", () => { expect(screen.queryByRole("button", { name: /edit settings/i })).not.toBeInTheDocument(); expect(screen.queryByText("Disable all global guardrails")).not.toBeInTheDocument(); }); + + it("refreshes deprecation banner data when the models list is refreshed", async () => { + const deprecatedResponse = { + checked_at: "2026-06-01T00:00:00Z", + warn_within_days: 30, + deprecated: [ + { + model_name: "gpt-4", + litellm_provider: "openai", + deprecation_date: "2026-06-01", + days_until_deprecation: -1, + status: "deprecated", + successor_model: "gpt-5", + }, + ], + imminent: [], + upcoming: [], + }; + const currentResponse = { + checked_at: "2026-06-02T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [], + upcoming: [], + }; + const fetchMock = vi + .fn() + .mockImplementationOnce(async (input) => { + const requestUrl = input instanceof Request ? input.url : String(input); + expect(new URL(requestUrl, "http://localhost").pathname).toBe("/model/deprecations"); + return Response.json(deprecatedResponse); + }) + .mockImplementationOnce(async (input) => { + const requestUrl = input instanceof Request ? input.url : String(input); + expect(new URL(requestUrl, "http://localhost").pathname).toBe("/model/deprecations"); + return Response.json(currentResponse); + }); + vi.stubGlobal("fetch", fetchMock); + + const user = userEvent.setup({ delay: null }); + renderWithProviders(); + + expect( + await screen.findByText("gpt-4 (openai) was deprecated on 2026-06-01, migrate to gpt-5"), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Refresh models" })); + await waitForElementToBeRemoved(() => screen.queryByRole("alert")); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index 3cfed7297c8..be8ee1bb8c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -29,6 +29,9 @@ vi.mock("./detailNavigation", () => ({ vi.mock("@/components/molecules/DecisionModelsBanner", () => ({ default: () =>
, })); +vi.mock("./components/ModelDeprecationBanner", () => ({ + default: () =>
, +})); vi.mock("@/components/model_info_view", () => ({ default: ({ modelId }: { modelId: string }) =>
model:{modelId}
, })); @@ -109,6 +112,12 @@ describe("ModelsAndEndpointsPage", () => { expect(screen.queryByRole("region", { name: "Decision models" })).not.toBeInTheDocument(); }); + it("renders the model deprecation banner", () => { + renderPage(); + + expect(screen.getByRole("region", { name: "Model deprecations" })).toBeInTheDocument(); + }); + it("renders the model detail overlay from the ?model drill-in and hides the tabs", () => { detailState.modelId = "abc-123"; renderPage(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index e0e5db8a8fd..b4082a14ffe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { ParseKeys } from "i18next"; import { useTranslation } from "react-i18next"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { invalidateModelQueries } from "@/app/(dashboard)/hooks/models/invalidateModelQueries"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { useUrlTab } from "@/hooks/useUrlTab"; @@ -30,6 +31,7 @@ import AccessGroupBudgetsPanel from "@/app/(dashboard)/models-and-endpoints/pane import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceDataPanel"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import ModelDeprecationBanner from "./components/ModelDeprecationBanner"; const MODEL_TAB_SLUGS = [ "add", @@ -166,10 +168,10 @@ export default function ModelsAndEndpointsPage() { const handleRefreshClick = () => { setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + void invalidateModelQueries(queryClient); }; - const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + const invalidateModels = () => invalidateModelQueries(queryClient); if (teamId) { return ( @@ -204,6 +206,7 @@ export default function ModelsAndEndpointsPage() {
{!isViewOnly && } + {modelId ? ( { +const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction, showBanner = false) => { const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); - renderWithProviders(, { searchParams, onUrlUpdate }); + renderWithProviders( + showBanner ? ( + <> + + + + ) : ( + + ), + { searchParams, onUrlUpdate }, + ); await screen.findByText("Provider"); const openAdvanced = async () => { @@ -186,11 +200,69 @@ const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction) = describe("AddModelPanel submit payload contract", () => { beforeEach(() => { vi.clearAllMocks(); + testQueryClient.clear(); mockUseModelCostMap.mockReturnValue({ data: {} }); mockPtuEnabled.mockReturnValue(false); mockAuthorized.mockReturnValue(PROXY_ADMIN); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refreshes deprecation banner data after adding a model", async () => { + const deprecatedResponse = { + checked_at: "2026-06-01T00:00:00Z", + warn_within_days: 30, + deprecated: [ + { + model_name: "old-model", + litellm_provider: null, + deprecation_date: "2026-06-01", + days_until_deprecation: -1, + status: "deprecated", + successor_model: null, + }, + ], + imminent: [], + upcoming: [], + }; + const currentResponse = { + checked_at: "2026-06-02T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [ + { + model_name: "current-model", + litellm_provider: null, + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + successor_model: null, + }, + ], + upcoming: [], + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(deprecatedResponse)) + .mockResolvedValueOnce(Response.json(currentResponse)); + vi.stubGlobal("fetch", fetchMock); + + const { fillRequired, submit } = await setup(undefined, undefined, true); + + expect(await screen.findByText("old-model was deprecated on 2026-06-01")).toBeInTheDocument(); + await fillRequired(); + await submit(); + expect(await screen.findByText("current-model deprecates on 2026-11-30, in 26 days")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect( + fetchMock.mock.calls.map( + ([input]) => new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname, + ), + ).toEqual(["/model/deprecations", "/model/deprecations"]); + }); + it("lists catalog models in the model picker, not entries registered at runtime for deployments", async () => { mockUseModelCostMap.mockImplementation((_enabled: boolean, catalogOnly: boolean) => ({ data: catalogOnly diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 392a3c3940a..13bec9506e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -15,6 +15,7 @@ import { Providers, getPlaceholder, getProviderModels } from "@/components/provi import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; +import { invalidateModelQueries } from "@/app/(dashboard)/hooks/models/invalidateModelQueries"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { buildDecisionCatalog } from "@/lib/decisionModels"; @@ -65,7 +66,7 @@ export default function AddModelPanel() { const decisionCatalog = useMemo(() => buildDecisionCatalog(modelCostMapData), [modelCostMapData]); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + const refresh = () => invalidateModelQueries(queryClient); const mountedValues = () => projectMountedValues(registry, form.getValues); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx index 2e27a258a84..f4ff11494da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.test.tsx @@ -1,7 +1,9 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { modelDeprecationKeys } from "@/app/(dashboard)/hooks/models/useModelDeprecations"; import AgentBuilderView from "./AgentBuilderView"; import type { AgentModel } from "../../llm_calls/fetch_agents"; @@ -67,10 +69,41 @@ const props = { userRole: "Admin", }; +const modelListKey = ["models", "list"] as const; + +const ModelQueryProbe = ({ + modelListQuery, + modelDeprecationQuery, +}: { + modelListQuery: () => Promise; + modelDeprecationQuery: () => Promise; +}) => { + useQuery({ queryKey: modelListKey, queryFn: modelListQuery, staleTime: Infinity }); + useQuery({ queryKey: modelDeprecationKeys.all, queryFn: modelDeprecationQuery, staleTime: Infinity }); + return null; +}; + const controlUnder = (label: string): HTMLElement => within(screen.getByText(label).parentElement!).getByRole("combobox"); -const renderView = () => render(); +const renderWithQueryClient = (children: ReactNode, queryClient = new QueryClient()) => { + return render({children}); +}; + +const renderView = (queryClient?: QueryClient) => renderWithQueryClient(, queryClient); + +const renderViewWithModelQueryProbe = ( + queryClient: QueryClient, + modelListQuery: () => Promise, + modelDeprecationQuery: () => Promise, +) => + renderWithQueryClient( + <> + + + , + queryClient, + ); const waitForRoster = async () => { await screen.findByRole("button", { name: "support-agent litellm_agent" }); @@ -99,7 +132,7 @@ beforeEach(() => { describe("AgentBuilderView", () => { it("asks the visitor to sign in when there is no session", () => { - render(); + renderWithQueryClient(); expect(screen.getByText("Sign in to use Agent Builder.")).toBeInTheDocument(); expect(fetchAvailableAgentModels).not.toHaveBeenCalled(); @@ -170,6 +203,25 @@ describe("AgentBuilderView", () => { expect(payload.litellm_params.model).toBe("litellm_agent/gpt-4o"); }); + it("refetches model queries after creating an agent", async () => { + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const modelListQuery = vi.fn().mockResolvedValue([]); + const modelDeprecationQuery = vi.fn().mockResolvedValue({}); + renderViewWithModelQueryProbe(queryClient, modelListQuery, modelDeprecationQuery); + await waitForRoster(); + await waitFor(() => expect(modelListQuery).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(modelDeprecationQuery).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: /New agent/i })); + fireEvent.change(screen.getByPlaceholderText("My Agent"), { target: { value: "billing-agent" } }); + await user.click(screen.getByRole("button", { name: /Save Agent/i })); + + await waitFor(() => expect(modelCreateCall).toHaveBeenCalled()); + await waitFor(() => expect(modelListQuery).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(modelDeprecationQuery).toHaveBeenCalledTimes(2)); + }); + it("will not save a draft without a name", async () => { const user = userEvent.setup(); renderView(); @@ -193,6 +245,24 @@ describe("AgentBuilderView", () => { expect(modelPatchUpdateCall.mock.calls[0][2]).toBe("agent-1"); }); + it("refetches model queries after updating an agent", async () => { + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const modelListQuery = vi.fn().mockResolvedValue([]); + const modelDeprecationQuery = vi.fn().mockResolvedValue({}); + renderViewWithModelQueryProbe(queryClient, modelListQuery, modelDeprecationQuery); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + await waitFor(() => expect(modelListQuery).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(modelDeprecationQuery).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: /Update Agent/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + await waitFor(() => expect(modelListQuery).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(modelDeprecationQuery).toHaveBeenCalledTimes(2)); + }); + it("deletes only after the warning is confirmed", async () => { const user = userEvent.setup(); renderView(); @@ -210,6 +280,27 @@ describe("AgentBuilderView", () => { await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("sk-access", "agent-1")); }); + it("refetches model queries after deleting an agent", async () => { + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const modelListQuery = vi.fn().mockResolvedValue([]); + const modelDeprecationQuery = vi.fn().mockResolvedValue({}); + renderViewWithModelQueryProbe(queryClient, modelListQuery, modelDeprecationQuery); + await waitForRoster(); + await screen.findByDisplayValue("support-agent"); + await waitFor(() => expect(modelListQuery).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(modelDeprecationQuery).toHaveBeenCalledTimes(1)); + + await user.click(screen.getAllByRole("button", { name: /Delete$/ })[0]); + await screen.findByText(/Are you sure you want to delete "support-agent"/); + const deleteButtons = screen.getAllByRole("button", { name: /Delete$/ }); + await user.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => expect(modelDeleteCall).toHaveBeenCalledWith("sk-access", "agent-1")); + await waitFor(() => expect(modelListQuery).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(modelDeprecationQuery).toHaveBeenCalledTimes(2)); + }); + it("abandons the delete when the warning is dismissed", async () => { const user = userEvent.setup(); renderView(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx index f9909462ee9..c79c539cac9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx @@ -3,6 +3,8 @@ import { Bot, FlaskConical, Link as LinkIcon, MessageSquare, Plus, Save, Trash2 } from "lucide-react"; import React, { useCallback, useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { invalidateModelQueries } from "@/app/(dashboard)/hooks/models/invalidateModelQueries"; import { AlertDialog, AlertDialogAction, @@ -203,6 +205,7 @@ export default function AgentBuilderView({ customProxyBaseUrl, }: AgentBuilderViewProps) { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [agentModels, setAgentModels] = useState([]); const [modelGroups, setModelGroups] = useState([]); const [loadingAgents, setLoadingAgents] = useState(true); @@ -358,6 +361,7 @@ export default function AgentBuilderView({ }, model_info: {}, }); + void invalidateModelQueries(queryClient); // /model/new returns the row with `model_id` at the top level. // Prefer that id over name-matching so we land on the just-created // agent even when its public name collides with another team's. @@ -397,6 +401,7 @@ export default function AgentBuilderView({ }, selectedAgentModelId, ); + void invalidateModelQueries(queryClient); toast.success(t("playground.chatUi.agentBuilderView.updated")); const list = await loadAgents(); const stillSelected = list.find((a) => getAgentModelId(a) === selectedAgentModelId); @@ -442,6 +447,7 @@ export default function AgentBuilderView({ setDeleting(true); try { await modelDeleteCall(accessToken, selectedAgentModelId); + void invalidateModelQueries(queryClient); toast.success(t("playground.chatUi.agentBuilderView.deleted")); const list = await loadAgents(); const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId); diff --git a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx index a42db06d251..82221826a68 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.integration.test.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React, { ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ModelInfoView from "./model_info_view"; +import ModelDeprecationBanner from "@/app/(dashboard)/models-and-endpoints/components/ModelDeprecationBanner"; import { toast } from "@/lib/toast"; import * as networking from "./networking"; import { useUserLookup } from "@/app/(dashboard)/hooks/users/useUsers"; @@ -45,6 +46,10 @@ vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: (...args: any[]) => mockUseModelCostMap(...args), })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "test-token", userRole: "Admin" }), +})); + const mockUseTeams = vi.fn(); vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => mockUseTeams(), @@ -77,6 +82,8 @@ const mockModelDeleteCall = vi.mocked(networking.modelDeleteCall); const mockCredentialCreateCall = vi.mocked(networking.credentialCreateCall); const mockVectorStoreListCall = vi.mocked(networking.vectorStoreListCall); +const TEST_TODAY = new Date(2026, 10, 4); + describe("ModelInfoView", () => { let queryClient: QueryClient; @@ -195,6 +202,10 @@ describe("ModelInfoView", () => { mockCredentialCreateCall.mockResolvedValue({}); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + const wrapper = ({ children }: { children: ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); @@ -205,6 +216,70 @@ describe("ModelInfoView", () => { }); }); + it("refreshes deprecation banner data after updating model credentials", async () => { + const deprecatedResponse = { + checked_at: "2026-06-01T00:00:00Z", + warn_within_days: 30, + deprecated: [ + { + model_name: "old-model", + litellm_provider: null, + deprecation_date: "2026-06-01", + days_until_deprecation: -1, + status: "deprecated", + successor_model: null, + }, + ], + imminent: [], + upcoming: [], + }; + const currentResponse = { + checked_at: "2026-06-02T00:00:00Z", + warn_within_days: 30, + deprecated: [], + imminent: [ + { + model_name: "current-model", + litellm_provider: null, + deprecation_date: "2026-11-30", + days_until_deprecation: 26, + status: "imminent", + successor_model: null, + }, + ], + upcoming: [], + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(deprecatedResponse)) + .mockResolvedValueOnce(Response.json(currentResponse)); + vi.stubGlobal("fetch", fetchMock); + + render( + <> + + + , + { wrapper }, + ); + + expect(await screen.findByText("old-model was deprecated on 2026-06-01")).toBeInTheDocument(); + await userEvent.click(screen.getByTestId("update-api-key-button")); + const dialog = screen.getByRole("dialog"); + fireEvent.change(within(dialog).getByPlaceholderText("Enter the new API key"), { + target: { value: "new-api-key" }, + }); + await userEvent.click(within(dialog).getByRole("button", { name: "Update API Key" })); + + expect(await screen.findByText("current-model deprecates on 2026-11-30, in 26 days")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect( + fetchMock.mock.calls.map( + ([input]) => new URL(input instanceof Request ? input.url : String(input), "http://localhost").pathname, + ), + ).toEqual(["/model/deprecations", "/model/deprecations"]); + }); + it("shows Kubernetes pods for discovery-enabled models", async () => { mockUseModelsInfo.mockReturnValue({ data: { diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index efa7caf550d..e0498fd1ea9 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -1,5 +1,6 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useModelHub, useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { invalidateModelQueries } from "@/app/(dashboard)/hooks/models/invalidateModelQueries"; import { useQueryClient } from "@tanstack/react-query"; import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer"; import { KeyIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline"; @@ -824,7 +825,7 @@ export default function ModelInfoView({ accessToken={accessToken} modelId={modelId} onUpdated={() => { - queryClient.invalidateQueries({ queryKey: ["models", "list"] }); + void invalidateModelQueries(queryClient); }} /> )} diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index 280bffd8fe9..9b4145c47ad 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import userEvent from "@testing-library/user-event"; import { FormProvider, useForm } from "react-hook-form"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../tests/test-utils"; import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking"; import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings"; @@ -77,15 +78,21 @@ describe("Settings", () => { }); it("should render the logging callbacks tab when access token is provided", async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); }); }); + it("opens the tab selected by the URL", async () => { + renderWithProviders(, { searchParams: { tab: "alerting-types" } }); + + expect(await screen.findByRole("tab", { name: "Alerting Types" })).toHaveAttribute("aria-selected", "true"); + }); + it("should display additional settings tabs", async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("CloudZero Cost Tracking")).toBeInTheDocument(); @@ -96,7 +103,7 @@ describe("Settings", () => { }); it("should load callback configs from the backend when access token is provided", async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken); @@ -140,7 +147,7 @@ describe("Settings", () => { ]); const user = userEvent.setup(); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); @@ -250,7 +257,7 @@ describe("Settings", () => { const openOtelEditModal = async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(await screen.findByTestId("callback-actions-otel-success")); await user.click(await screen.findByTestId("callback-action-edit")); return user; @@ -340,7 +347,7 @@ describe("Settings", () => { const openS3EditModal = async (callbackName = "s3") => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(await screen.findByTestId(`callback-actions-${callbackName}-success`)); await user.click(await screen.findByTestId("callback-action-edit")); return user; @@ -492,7 +499,7 @@ describe("Settings", () => { it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => { const user = userEvent.setup(); - render(); + renderWithProviders(); await user.click(await screen.findByRole("tab", { name: "Alerting Types" })); @@ -525,7 +532,7 @@ describe("Settings", () => { }), ); - render(); + renderWithProviders(); expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); @@ -540,7 +547,7 @@ describe("Settings", () => { }); it("should resolve loading without fetching when the user id is missing", async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument(); @@ -550,7 +557,7 @@ describe("Settings", () => { }); it("should display CloudZero Cost Tracking tab", async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 4153de19499..d2d65b9f526 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -24,6 +24,7 @@ import EmailSettings from "./email_settings"; import MSTeamsSettings from "./MSTeamsSettings"; import { Logo } from "@/components/molecules/logo/Logo"; import { toast } from "@/lib/toast"; +import { useUrlTab } from "@/hooks/useUrlTab"; import AlertingSettings from "./alerting/alerting_settings"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; @@ -46,6 +47,15 @@ interface SettingsPageProps { } type CallbackFormValues = Record; +const SETTINGS_TABS = [ + "logging-callbacks", + "cloudzero-cost-tracking", + "alerting-types", + "alerting-settings", + "email-alerts", + "ms-teams-alerts", +] as const; +type SettingsTab = (typeof SETTINGS_TABS)[number]; const assetsLogoFolder = "/ui/assets/logos/"; @@ -313,6 +323,7 @@ const buildCallbackPayload = (formValues: Record, callbackName: str }; const Settings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { + const [activeTab, setActiveTab] = useUrlTab(SETTINGS_TABS, "logging-callbacks"); const { t } = useTranslation(); const [callbacks, setCallbacks] = useState([]); const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); @@ -597,7 +608,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, return (
- + setActiveTab(value as SettingsTab)}> {t("loggingAndAlerts.page.tabs.loggingCallbacks")} {t("loggingAndAlerts.page.tabs.cloudZero")} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e65a30f6e1d..a2073bb00d8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -39049,6 +39049,11 @@ export interface components { * @description The model to migrate to: model_info.successor_model on the deployment when set, else the successor_model the cost map names for the deprecated model. */ successor_model?: string | null; + /** + * Team Public Model Name + * @description The model name visible to the team, when configured. + */ + team_public_model_name?: string | null; }; /** ModelDeprecationResponse */ ModelDeprecationResponse: {