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 <krrishdholakia@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Krrish Dholakia 2026-11-04 18:50:14 -08:00 • committed by GitHub
parent 429f814a04
commit c5da69cd13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1785 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
import type { QueryClient } from "@tanstack/react-query";
import { modelDeprecationKeys } from "./useModelDeprecations";
export const invalidateModelQueries = async (queryClient: QueryClient): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["models", "list"] }),
queryClient.invalidateQueries({ queryKey: modelDeprecationKeys.all }),
]);
};

View file

@ -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<typeof vi.fn<typeof fetch>>;
beforeEach(() => {
authState.accessToken = "token";
authState.userRole = "internal_user";
fetchMock = vi.fn<typeof fetch>().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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
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",
);
});
});

View file

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

View file

@ -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<AutoRouterDeployment[], Error>
export const useInvalidateAutoRouters = (): (() => Promise<void>) => {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
};
return () => invalidateModelQueries(queryClient);
};
export const useModelHub = () => {

View file

@ -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 ? <div data-testid="model-settings-modal" /> : null;
},
}));
let modelsInfoResult: Record<string, unknown> = {};
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<string, unknown>[]) => {
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<typeof fetch>()
.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(
<>
<AllModelsTab {...defaultProps} />
<ModelDeprecationBanner today={TEST_TODAY} />
</>,
);
const ModelListQueryProbe = ({ queryFn }: { queryFn: () => Promise<string> }) => {
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<string>>().mockResolvedValue("models");
const user = userEvent.setup();
renderWithProviders(
<>
<AllModelsTab {...defaultProps} />
<ModelDeprecationBanner today={TEST_TODAY} />
<ModelListQueryProbe queryFn={observerQueryFn} />
</>,
);
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);
});
});

View file

@ -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<typeof import("@tanstack/react-query")>();
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);

View file

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

View file

@ -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> = {}): ModelDeprecationResponse => ({
checked_at: "2026-11-04T00:00:00Z",
warn_within_days: 30,
deprecated: [],
imminent: [],
upcoming: [],
...overrides,
});
const makeInfo = (overrides: Partial<ModelDeprecationInfo> = {}): 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<typeof useModelDeprecations>> = {},
): ReturnType<typeof useModelDeprecations> =>
({ data, isLoading, isError, ...overrides }) as ReturnType<typeof useModelDeprecations>;
const pinnedToday = (): Date => new Date(2026, 10, 4);
const renderBanner = (today = pinnedToday()) => render(<ModelDeprecationBanner today={today} />);
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
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(<ModelDeprecationBanner today={pinnedToday()} />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps the banner visible when a refetch fails", () => {
const staleInfoOverrides: Partial<ModelDeprecationInfo> = {
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<ReturnType<typeof useModelDeprecations>> = {
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
model_name: "o4-mini",
team_public_model_name: null,
litellm_provider: "openai",
deprecation_date: "2026-10-23",
status: "deprecated",
};
const firstTeamInfoOverrides: Partial<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
model_name: "shared-model",
litellm_provider: "openai",
deprecation_date: "2026-10-22",
days_until_deprecation: -2,
status: "deprecated",
successor_model: "successor",
};
const secondEntryOverrides: Partial<ModelDeprecationInfo> = {
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<ModelDeprecationInfo> = {
...secondInfo,
days_until_deprecation: secondInfo.days_until_deprecation - 1,
};
const changedInfoOverrides: Partial<ModelDeprecationInfo> = {
...info,
days_until_deprecation: info.days_until_deprecation - 1,
};
const teamAliasInfoOverrides: Partial<ModelDeprecationInfo> = {
...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<ModelDeprecationInfo> = {
model_name: "gpt-4",
deprecation_date: "2026-10-23",
days_until_deprecation: -12,
status: "deprecated",
};
const firstEntry = makeInfo(firstEntryOverrides);
const addedEntryOverrides: Partial<ModelDeprecationInfo> = {
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(<ModelDeprecationBanner today={pinnedToday()} />);
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<ModelDeprecationInfo> = {
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(<ModelDeprecationBanner today={new Date(2026, 11, 1)} />);
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(<ModelDeprecationBanner today={pinnedToday()} />);
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();
});
});

View file

@ -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<string | null>(() =>
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 (
<Alert variant={deprecated.length > 0 ? "destructive" : "default"} className="mb-4">
<TriangleAlert />
<AlertAction>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
setDismissedFingerprint(fingerprint);
setLocalStorageItem(STORAGE_KEY, fingerprint);
}}
aria-label="Dismiss deprecation warning"
>
<X />
</Button>
</AlertAction>
<AlertTitle>
{deprecationBannerTitle(countDistinctModels(deprecated), countDistinctModels(imminent), data.warn_within_days)}
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{visibleEntries.map((info, index) => (
<li key={`${info.model_name}-${info.deprecation_date}-${index}`}>{deprecationEntryText(info)}</li>
))}
</ul>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
{entries.length > 5 && (
<button
type="button"
className="underline"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((expanded) => !expanded)}
>
{isExpanded ? "Show fewer" : `Show all ${entries.length}`}
</button>
)}
<a href={`${uiHref("logging-and-alerts")}?tab=alerting-types`} className="font-medium underline">
Get Slack alerts for deprecations
</a>
</div>
</AlertDescription>
</Alert>
);
};
export default ModelDeprecationBanner;

View file

@ -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 }) => <button onClick={onReloadSuccess}>reload</button>,
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test", userRole: "Admin" }),
}));
const renderTabWithBanner = (queryClient: QueryClient) =>
render(
<QueryClientProvider client={queryClient}>
<>
<PriceDataManagementTab />
<ModelDeprecationBanner today={TEST_TODAY} />
</>
</QueryClientProvider>,
);
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<typeof fetch>()
.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"]);
});
});

View file

@ -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 }) => <button onClick={onReloadSuccess}>reload</button>,
}));
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);
});
});

View file

@ -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 = () => {
<PriceDataReload
accessToken={accessToken}
onReloadSuccess={() => {
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"

View file

@ -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> = {}): 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");
});
});

View file

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

View file

@ -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: () => <div data-testid="panel-all-models" /> }));
@ -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<typeof fetch>()
.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(<ModelsAndEndpointsPage />);
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);
});
});

View file

@ -29,6 +29,9 @@ vi.mock("./detailNavigation", () => ({
vi.mock("@/components/molecules/DecisionModelsBanner", () => ({
default: () => <section aria-label="Decision models" />,
}));
vi.mock("./components/ModelDeprecationBanner", () => ({
default: () => <section aria-label="Model deprecations" />,
}));
vi.mock("@/components/model_info_view", () => ({
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
}));
@ -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();

View file

@ -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() {
</div>
{!isViewOnly && <DecisionModelsBanner />}
<ModelDeprecationBanner />
{modelId ? (
<ModelInfoView

View file

@ -2,8 +2,10 @@ import { useSyncExternalStore } from "react";
import { act, fireEvent, renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testQueryClient } from "../../../../../tests/test-utils";
import AddModelPanel from "./AddModelPanel";
import ModelDeprecationBanner from "../components/ModelDeprecationBanner";
const modelCreateCall = vi.fn();
const mockPtuEnabled = vi.fn();
@ -147,11 +149,23 @@ const advancedOpenExtras = {
const baseModelInfo = { access_groups: undefined, mode: undefined };
const TEST_TODAY = new Date(2026, 10, 4);
const { api_base: _omitted, ...ALWAYS_MOUNTED_WITHOUT_API_BASE } = alwaysMounted;
const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction) => {
const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction, showBanner = false) => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderWithProviders(<AddModelPanel />, { searchParams, onUrlUpdate });
renderWithProviders(
showBanner ? (
<>
<AddModelPanel />
<ModelDeprecationBanner today={TEST_TODAY} />
</>
) : (
<AddModelPanel />
),
{ 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<typeof fetch>()
.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

View file

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

View file

@ -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<unknown>;
modelDeprecationQuery: () => Promise<unknown>;
}) => {
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(<AgentBuilderView {...props} />);
const renderWithQueryClient = (children: ReactNode, queryClient = new QueryClient()) => {
return render(<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>);
};
const renderView = (queryClient?: QueryClient) => renderWithQueryClient(<AgentBuilderView {...props} />, queryClient);
const renderViewWithModelQueryProbe = (
queryClient: QueryClient,
modelListQuery: () => Promise<unknown>,
modelDeprecationQuery: () => Promise<unknown>,
) =>
renderWithQueryClient(
<>
<AgentBuilderView {...props} />
<ModelQueryProbe modelListQuery={modelListQuery} modelDeprecationQuery={modelDeprecationQuery} />
</>,
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(<AgentBuilderView accessToken={null} token={null} userID={null} userRole={null} />);
renderWithQueryClient(<AgentBuilderView accessToken={null} token={null} userID={null} userRole={null} />);
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();

View file

@ -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<AgentModel[]>([]);
const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
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);

View file

@ -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<typeof fetch>()
.mockResolvedValueOnce(Response.json(deprecatedResponse))
.mockResolvedValueOnce(Response.json(currentResponse));
vi.stubGlobal("fetch", fetchMock);
render(
<>
<ModelInfoView {...DEFAULT_ADMIN_PROPS} />
<ModelDeprecationBanner today={TEST_TODAY} />
</>,
{ 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: {

View file

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

View file

@ -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(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument();
});
});
it("opens the tab selected by the URL", async () => {
renderWithProviders(<Settings {...defaultProps} />, { searchParams: { tab: "alerting-types" } });
expect(await screen.findByRole("tab", { name: "Alerting Types" })).toHaveAttribute("aria-selected", "true");
});
it("should display additional settings tabs", async () => {
render(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
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(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
await waitFor(() => {
expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken);
@ -140,7 +147,7 @@ describe("Settings", () => {
]);
const user = userEvent.setup();
render(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument();
@ -250,7 +257,7 @@ describe("Settings", () => {
const openOtelEditModal = async () => {
const user = userEvent.setup();
render(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
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(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
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(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
await user.click(await screen.findByRole("tab", { name: "Alerting Types" }));
@ -525,7 +532,7 @@ describe("Settings", () => {
}),
);
render(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
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(<Settings {...defaultProps} userID={null as unknown as string} />);
renderWithProviders(<Settings {...defaultProps} userID={null as unknown as string} />);
await waitFor(() => {
expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument();
@ -550,7 +557,7 @@ describe("Settings", () => {
});
it("should display CloudZero Cost Tracking tab", async () => {
render(<Settings {...defaultProps} />);
renderWithProviders(<Settings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument();

View file

@ -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<string, string>;
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<string, any>, callbackName: str
};
const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, premiumUser }) => {
const [activeTab, setActiveTab] = useUrlTab(SETTINGS_TABS, "logging-callbacks");
const { t } = useTranslation();
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true);
@ -597,7 +608,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
return (
<div className="mx-4">
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
<Tabs defaultValue="logging-callbacks">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as SettingsTab)}>
<TabsList variant="line">
<TabsTrigger value="logging-callbacks">{t("loggingAndAlerts.page.tabs.loggingCallbacks")}</TabsTrigger>
<TabsTrigger value="cloudzero-cost-tracking">{t("loggingAndAlerts.page.tabs.cloudZero")}</TabsTrigger>

View file

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