feat(proxy): add model deprecation alerts with daily requests by key (#50022)

* feat(ui): show recent model deprecations in notification bell

* feat(ui): simplify deprecation request trends and add model identity
This commit is contained in:
ishaan-j-berri 2026-11-05 12:03:42 +08:00 • committed by GitHub
parent 5c60824024
commit d2b2e76374
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2180 additions and 1589 deletions

View file

@ -0,0 +1 @@
"""HTTP endpoints for gateway alerts."""

View file

@ -0,0 +1,109 @@
"""Shared acknowledgement state for model migration alerts."""
import hashlib
import json
from datetime import date, datetime, timezone
from typing import Annotated, Final
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import TypeAdapter
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.deps.database import DatabaseDep
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.daily_activity_buckets import api_key_bucket, api_key_from_bucket
from litellm.repositories.table_repositories import DailyTagSpendRepository
from litellm.types.proxy.auth.user_roles import LitellmUserRoles
from litellm.types.proxy.model_deprecation import DeprecationAlertState, DeprecationAlertUpdate, DeprecationTagUsage
router: Final = APIRouter()
def alert_state_repository(database: DatabaseDep) -> ConfigRepository:
if database is None:
raise HTTPException(status_code=503, detail="Alert state requires a database connection")
return ConfigRepository(database.prisma, use_writer=True)
def alert_tag_repository(database: DatabaseDep) -> DailyTagSpendRepository:
if database is None:
raise HTTPException(status_code=503, detail="Model usage tags require a database connection")
return DailyTagSpendRepository(database.prisma)
def _state_key(model: str, deprecation_date: date) -> str:
identity: Final = json.dumps([model, deprecation_date.isoformat()])
digest: Final = hashlib.sha256(identity.encode()).hexdigest()
return f"model_deprecation_alert:{digest}"
def _require_admin(user: UserAPIKeyAuth, *, write: bool) -> None:
if user.user_role == LitellmUserRoles.PROXY_ADMIN:
return
if not write and user.user_role == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY:
return
raise HTTPException(status_code=403, detail="Only gateway admins can manage model deprecation alerts")
@router.get("/alerting/model-deprecations/state", tags=["alerting"], response_model=DeprecationAlertState)
async def get_deprecation_alert_state(
model: Annotated[str, Query(min_length=1, max_length=1024)],
deprecation_date: date,
user: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[ConfigRepository, Depends(alert_state_repository)],
) -> DeprecationAlertState:
_require_admin(user, write=False)
record: Final = await repository.get_param(_state_key(model, deprecation_date))
if record is None:
return DeprecationAlertState(model=model, deprecation_date=deprecation_date)
return DeprecationAlertState.model_validate(record.param_value)
@router.put("/alerting/model-deprecations/state", tags=["alerting"], response_model=DeprecationAlertState)
async def update_deprecation_alert_state(
data: DeprecationAlertUpdate,
user: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[ConfigRepository, Depends(alert_state_repository)],
) -> DeprecationAlertState:
_require_admin(user, write=True)
state: Final = DeprecationAlertState(
**data.model_dump(), updated_at=datetime.now(timezone.utc), updated_by=user.user_id
)
await repository.set_param(_state_key(data.model, data.deprecation_date), state.model_dump(mode="json"))
return state
@router.get("/alerting/model-deprecations/tags", tags=["alerting"], response_model=tuple[DeprecationTagUsage, ...])
async def get_deprecation_usage_tags(
model: Annotated[str, Query(min_length=1, max_length=1024)],
api_keys: Annotated[tuple[str, ...], Query(min_length=1, max_length=100)],
start_date: date,
end_date: date,
user: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyTagSpendRepository, Depends(alert_tag_repository)],
) -> tuple[DeprecationTagUsage, ...]:
"""Distinct tags observed with this model for a page of affected reporting key IDs."""
_require_admin(user, write=False)
if not 0 <= (end_date - start_date).days < 30:
raise HTTPException(status_code=422, detail="Select up to 30 consecutive calendar dates")
rows: Final = await repository.table.group_by(
by=["api_key", "tag"],
where={
"api_key": {
# bounded-ok: query validation limits api_keys to 100 IDs.
"in": [api_key_from_bucket(key) for key in api_keys]
},
"model": model,
"date": {"gte": start_date.isoformat(), "lte": end_date.isoformat()},
"api_requests": {"gt": 0},
"tag": {"not": None},
},
)
tags: Final = TypeAdapter(tuple[DeprecationTagUsage, ...]).validate_python(rows)
return tuple(
sorted(
(DeprecationTagUsage(api_key=api_key_bucket(usage.api_key), tag=usage.tag) for usage in tags),
key=lambda usage: (usage.api_key, usage.tag),
)
)

View file

@ -424,6 +424,9 @@ from litellm.messages.dispatch import messages_target
from litellm.proxy._lazy_features import attach_lazy_features, reserve_lazy_slot
from litellm.proxy._types import *
from litellm.proxy.agent_endpoints.auth.agent_access_groups import CeilingResolver, resolve_agent_access_group_ceiling
from litellm.proxy.alerting_endpoints.model_deprecation_alerts import (
router as model_deprecation_alerts_router,
)
from litellm.proxy.analytics_endpoints.analytics_endpoints import (
router as analytics_router,
)
@ -22434,6 +22437,7 @@ app.include_router(prompt_caching_requests_router)
app.include_router(router_settings_router)
app.include_router(fallback_management_router)
app.include_router(cache_settings_router)
app.include_router(model_deprecation_alerts_router)
app.include_router(coordination_redis_settings_router)
app.include_router(user_agent_analytics_router)
app.include_router(gateway_request_router)

View file

@ -1,9 +1,10 @@
from __future__ import annotations
from datetime import date, datetime
from enum import Enum
from typing import Final, Literal
from pydantic import Field
from pydantic import ConfigDict, Field
from litellm.types.llms.base import LiteLLMBaseModel
@ -63,3 +64,38 @@ class ModelDeprecationResponse(LiteLLMBaseModel):
)
warn_within_days: int = Field(description="The window (in days) used to bucket 'imminent' models.")
checked_at: datetime = Field(description="UTC timestamp when the deprecation snapshot was generated.")
class DeprecationAlertStatus(str, Enum):
OPEN = "open"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class DeprecationAlertUpdate(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
model: str = Field(min_length=1, max_length=1024)
deprecation_date: date
status: DeprecationAlertStatus
last_seen_date: date
last_seen_requests: int = Field(ge=0)
class DeprecationAlertState(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
model: str
deprecation_date: date
status: DeprecationAlertStatus = DeprecationAlertStatus.OPEN
last_seen_date: date | None = None
last_seen_requests: int = 0
updated_at: datetime | None = None
updated_by: str | None = None
class DeprecationTagUsage(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
api_key: str
tag: str

View file

@ -0,0 +1,180 @@
from collections.abc import Iterator
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.alerting_endpoints.model_deprecation_alerts import alert_state_repository, router
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.repositories.config_repository import ConfigParam, ConfigRepository
from litellm.types.proxy.auth.user_roles import LitellmUserRoles
class MemoryConfig(ConfigRepository):
def __init__(self) -> None:
self.rows: dict[str, object] = {}
async def get_param(self, param_name: str) -> ConfigParam | None:
value = self.rows.get(param_name)
return None if value is None else ConfigParam(param_name, value)
async def set_param(self, param_name: str, param_value: object) -> ConfigParam:
self.rows[param_name] = param_value
return ConfigParam(param_name, param_value)
@pytest.fixture()
def client() -> Iterator[tuple[TestClient, UserAPIKeyAuth]]:
app = FastAPI()
app.include_router(router)
user = UserAPIKeyAuth(user_id="admin-one", user_role=LitellmUserRoles.PROXY_ADMIN)
repository = MemoryConfig()
app.dependency_overrides[user_api_key_auth] = lambda: user
app.dependency_overrides[alert_state_repository] = lambda: repository
with TestClient(app) as http:
yield http, user
IDENTITY = {"model": "anthropic/claude-sonnet-4-5", "deprecation_date": "2026-11-30"}
UPDATE = {**IDENTITY, "status": "resolved", "last_seen_date": "2026-11-04", "last_seen_requests": 123}
PATH = "/alerting/model-deprecations/state"
def test_new_alert_is_open(client: tuple[TestClient, UserAPIKeyAuth]) -> None:
http, _ = client
response = http.get(PATH, params=IDENTITY)
assert response.status_code == 200
assert response.json()["status"] == "open"
def test_resolution_is_shared_and_keeps_the_usage_snapshot(client: tuple[TestClient, UserAPIKeyAuth]) -> None:
http, user = client
response = http.put(PATH, json=UPDATE)
assert response.status_code == 200
assert response.json()["updated_by"] == "admin-one"
user.user_id = "admin-two"
observed = http.get(PATH, params=IDENTITY).json()
assert observed["status"] == "resolved"
assert observed["last_seen_date"] == "2026-11-04"
assert observed["last_seen_requests"] == 123
assert observed["updated_by"] == "admin-one"
def test_reopen_does_not_change_another_models_resolution(client: tuple[TestClient, UserAPIKeyAuth]) -> None:
http, _ = client
http.put(PATH, json=UPDATE)
other = {**UPDATE, "model": "openai/gpt-4-turbo"}
http.put(PATH, json=other)
assert http.put(PATH, json={**UPDATE, "status": "open"}).json()["status"] == "open"
assert http.get(PATH, params={**IDENTITY, "model": other["model"]}).json()["status"] == "resolved"
@pytest.mark.parametrize("method", ["get", "put"])
def test_internal_users_cannot_read_or_change_global_alerts(
client: tuple[TestClient, UserAPIKeyAuth], method: str
) -> None:
http, user = client
user.user_role = LitellmUserRoles.INTERNAL_USER
response = http.get(PATH, params=IDENTITY) if method == "get" else http.put(PATH, json=UPDATE)
assert response.status_code == 403
def test_viewer_can_read_but_cannot_resolve_at_the_same_path(client: tuple[TestClient, UserAPIKeyAuth]) -> None:
http, user = client
user.user_role = LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
assert http.get(PATH, params=IDENTITY).status_code == 200
assert http.put(PATH, json=UPDATE).status_code == 403
assert http.get(PATH, params=IDENTITY).json()["status"] == "open"
@pytest.mark.parametrize(
"field,value", [("status", "unknown"), ("last_seen_requests", -1), ("deprecation_date", "invalid"), ("model", "")]
)
def test_invalid_updates_are_rejected(client: tuple[TestClient, UserAPIKeyAuth], field: str, value: object) -> None:
http, _ = client
assert http.put(PATH, json={**UPDATE, field: value}).status_code == 422
assert http.get(PATH, params=IDENTITY).json()["status"] == "open"
def test_tags_are_scoped_to_model_dates_and_the_visible_keys(client: tuple[TestClient, UserAPIKeyAuth]) -> None:
from types import SimpleNamespace
from unittest.mock import AsyncMock
from litellm.proxy.alerting_endpoints.model_deprecation_alerts import alert_tag_repository
http, _ = client
grouped = AsyncMock(return_value=[{"api_key": "reporting-one", "tag": "support"}])
http.app.dependency_overrides[alert_tag_repository] = lambda: SimpleNamespace(
table=SimpleNamespace(group_by=grouped)
)
response = http.get(
"/alerting/model-deprecations/tags",
params={
"model": "openai/gpt-4-turbo",
"api_keys": ["reporting-one", "reporting-two"],
"start_date": "2026-10-06",
"end_date": "2026-11-04",
},
)
assert response.status_code == 200
assert response.json() == [{"api_key": "reporting-one", "tag": "support"}]
assert grouped.call_args.kwargs["where"] == {
"api_key": {"in": ["reporting-one", "reporting-two"]},
"model": "openai/gpt-4-turbo",
"date": {"gte": "2026-10-06", "lte": "2026-11-04"},
"api_requests": {"gt": 0},
"tag": {"not": None},
}
@pytest.mark.parametrize(
"role,expected", [(LitellmUserRoles.INTERNAL_USER, 403), (LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, 200)]
)
def test_tag_access_matches_alert_visibility(
client: tuple[TestClient, UserAPIKeyAuth], role: LitellmUserRoles, expected: int
) -> None:
from types import SimpleNamespace
from unittest.mock import AsyncMock
from litellm.proxy.alerting_endpoints.model_deprecation_alerts import alert_tag_repository
http, user = client
user.user_role = role
grouped = AsyncMock(return_value=[])
http.app.dependency_overrides[alert_tag_repository] = lambda: SimpleNamespace(
table=SimpleNamespace(group_by=grouped)
)
response = http.get(
"/alerting/model-deprecations/tags",
params={"model": "test-model", "api_keys": ["one"], "start_date": "2026-10-06", "end_date": "2026-11-04"},
)
assert response.status_code == expected
if expected == 403:
grouped.assert_not_called()
def test_tag_query_rejects_more_than_100_keys_before_database_access(client: tuple[TestClient, UserAPIKeyAuth]) -> None:
from types import SimpleNamespace
from unittest.mock import AsyncMock
from litellm.proxy.alerting_endpoints.model_deprecation_alerts import (
alert_tag_repository,
)
http, _ = client
grouped = AsyncMock(return_value=[])
http.app.dependency_overrides[alert_tag_repository] = lambda: SimpleNamespace(
table=SimpleNamespace(group_by=grouped)
)
response = http.get(
"/alerting/model-deprecations/tags",
params={
"model": "test-model",
"api_keys": [f"reporting-{index}" for index in range(101)],
"start_date": "2026-10-07",
"end_date": "2026-11-05",
},
)
assert response.status_code == 422
grouped.assert_not_called()

View file

@ -0,0 +1,19 @@
"use client";
import { Page, PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page";
import ModelDeprecationsPanel from "@/components/ModelDeprecationAlerts/ModelDeprecationsPanel";
export default function AlertsPage() {
return (
<Page className="h-full gap-4">
<PageTabs defaultValue="model-deprecations" className="gap-4">
<PageTabsList>
<PageTabsTrigger value="model-deprecations">Model deprecations</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="model-deprecations">
<ModelDeprecationsPanel />
</PageTabsContent>
</PageTabs>
</Page>
);
}

View file

@ -1,9 +0,0 @@
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

@ -1,64 +0,0 @@
/* @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

@ -1,33 +0,0 @@
"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,7 +1,6 @@
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";
@ -241,7 +240,9 @@ export const useAutoRouters = (): UseQueryResult<AutoRouterDeployment[], Error>
export const useInvalidateAutoRouters = (): (() => Promise<void>) => {
const queryClient = useQueryClient();
return () => invalidateModelQueries(queryClient);
return async () => {
await queryClient.invalidateQueries({ queryKey: modelKeys.lists() });
};
};
export const useModelHub = () => {

View file

@ -31,6 +31,7 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
"ui-theme": "ui-theme",
telemetry: "telemetry",
logs: "logs",
alerts: "alerts",
lens: "lens",
"admin-panel": "admin-panel",
"logging-and-alerts": "logging-and-alerts",

View file

@ -1,232 +0,0 @@
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, testQueryClient } from "../../../../../tests/test-utils";
import { renderWithProviders } from "../../../../../tests/test-utils";
import AllModelsTab from "./AllModelsTab";
import { STATUS_COLUMN_ID, toServerSortField } from "./ModelsTableColumns";
@ -22,6 +22,12 @@ 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;
@ -151,7 +157,6 @@ const defaultProps = {
describe("AllModelsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
modelsInfoCalls.length = 0;
setModelsInfo([makeRow()]);
vi.spyOn(useAuthorizedModule, "default").mockReturnValue(MOCK_AUTHORIZED);

View file

@ -22,8 +22,6 @@ 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 {
@ -268,8 +266,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);
@ -293,7 +291,7 @@ const AllModelsTab = ({
);
// invalidateQueries already schedules a refetch for active observers
// on this key — no need to also call refetchModels() (would double-fetch).
void invalidateModelQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
} catch (error) {
console.error("Error toggling model pause state:", error);
toast.fromError(error);
@ -305,9 +303,8 @@ const AllModelsTab = ({
);
const handleRefresh = useCallback(() => {
refetchModels();
void queryClient.invalidateQueries({ queryKey: modelDeprecationKeys.all });
}, [queryClient, refetchModels]);
void refetchModels();
}, [refetchModels]);
const handleDeleteClick = useCallback((modelId: string) => {
setDeleteModalModelId(modelId);

View file

@ -1,477 +0,0 @@
/* @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

@ -1,98 +0,0 @@
"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

@ -1,83 +0,0 @@
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,16 +2,13 @@
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", userRole: "Admin" }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
const renderTab = (queryClient: QueryClient) =>
render(
@ -30,16 +27,13 @@ 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,7 +3,6 @@ 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 = () => {
@ -21,10 +20,7 @@ const PriceDataManagementTab = () => {
<PriceDataReload
accessToken={accessToken}
onReloadSuccess={() => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all }),
queryClient.invalidateQueries({ queryKey: modelDeprecationKeys.all }),
]);
queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all });
}}
buttonText={t("modelsAndEndpoints.priceDataManagementTab.reloadButton")}
size="middle"

View file

@ -1,140 +0,0 @@
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

@ -1,96 +0,0 @@
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, waitForElementToBeRemoved } from "@testing-library/react";
import { screen } 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, testQueryClient } from "../../../../tests/test-utils";
import { renderWithProviders } from "../../../../tests/test-utils";
import ModelsAndEndpointsPage from "./page";
vi.mock("./panels/AllModelsPanel", () => ({ default: () => <div data-testid="panel-all-models" /> }));
@ -201,7 +201,6 @@ const createMockTeamData = (overrides = {}) => ({
describe("ModelsAndEndpointsPage ?team drill-in", () => {
beforeEach(() => {
testQueryClient.clear();
authState.userRole = "Admin";
authState.isViewOnly = false;
can.mockReturnValue(true);
@ -222,7 +221,6 @@ describe("ModelsAndEndpointsPage ?team drill-in", () => {
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
@ -246,53 +244,4 @@ 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,9 +29,6 @@ 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>,
}));
@ -112,12 +109,6 @@ 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,7 +7,6 @@ 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";
@ -31,7 +30,6 @@ 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",
@ -168,10 +166,10 @@ export default function ModelsAndEndpointsPage() {
const handleRefreshClick = () => {
setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
void invalidateModelQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
};
const invalidateModels = () => invalidateModelQueries(queryClient);
const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
if (teamId) {
return (
@ -206,7 +204,6 @@ export default function ModelsAndEndpointsPage() {
</div>
{!isViewOnly && <DecisionModelsBanner />}
<ModelDeprecationBanner />
{modelId ? (
<ModelInfoView

View file

@ -2,10 +2,8 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { testQueryClient } from "../../../../../tests/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AddModelPanel from "./AddModelPanel";
import ModelDeprecationBanner from "../components/ModelDeprecationBanner";
const modelCreateCall = vi.fn();
const mockPtuEnabled = vi.fn();
@ -149,23 +147,11 @@ 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, showBanner = false) => {
const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction) => {
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
renderWithProviders(
showBanner ? (
<>
<AddModelPanel />
<ModelDeprecationBanner today={TEST_TODAY} />
</>
) : (
<AddModelPanel />
),
{ searchParams, onUrlUpdate },
);
renderWithProviders(<AddModelPanel />, { searchParams, onUrlUpdate });
await screen.findByText("Provider");
const openAdvanced = async () => {
@ -200,69 +186,11 @@ const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction, s
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,7 +15,6 @@ 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";
@ -66,7 +65,7 @@ export default function AddModelPanel() {
const decisionCatalog = useMemo(() => buildDecisionCatalog(modelCostMapData), [modelCostMapData]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const refresh = () => invalidateModelQueries(queryClient);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
const mountedValues = () => projectMountedValues(registry, form.getValues);

View file

@ -1,9 +1,7 @@
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, type ReactNode } from "react";
import { useState } 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";
@ -69,41 +67,10 @@ 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 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 renderView = () => render(<AgentBuilderView {...props} />);
const waitForRoster = async () => {
await screen.findByRole("button", { name: "support-agent litellm_agent" });
@ -132,7 +99,7 @@ beforeEach(() => {
describe("AgentBuilderView", () => {
it("asks the visitor to sign in when there is no session", () => {
renderWithQueryClient(<AgentBuilderView accessToken={null} token={null} userID={null} userRole={null} />);
render(<AgentBuilderView accessToken={null} token={null} userID={null} userRole={null} />);
expect(screen.getByText("Sign in to use Agent Builder.")).toBeInTheDocument();
expect(fetchAvailableAgentModels).not.toHaveBeenCalled();
@ -203,25 +170,6 @@ 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();
@ -245,24 +193,6 @@ 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();
@ -280,27 +210,6 @@ 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,8 +3,6 @@
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,
@ -205,7 +203,6 @@ 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);
@ -361,7 +358,6 @@ 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.
@ -401,7 +397,6 @@ 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);
@ -447,7 +442,6 @@ 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

@ -210,3 +210,27 @@ describe("model colors", () => {
expect(bucketTotals(series).get("2026-10-01")).toBe(55);
});
});
describe("daily key stacks", () => {
it("should preserve each day's total when truncated keys are grouped into Other", () => {
const first = day("2026-11-01", 10, {});
first.metrics.api_requests = 100;
first.breakdown.api_keys = {
"key-a": { metrics: { ...metrics(3), api_requests: 30 }, metadata: { key_alias: "Support", team_id: null } },
"key-b": { metrics: { ...metrics(2), api_requests: 20 }, metadata: { key_alias: "Research", team_id: null } },
};
const second = day("2026-11-02", 15, {});
second.metrics.api_requests = 150;
second.breakdown.api_keys = {
"key-a": { metrics: { ...metrics(1), api_requests: 10 }, metadata: { key_alias: "Support", team_id: null } },
"key-b": { metrics: { ...metrics(8), api_requests: 80 }, metadata: { key_alias: "Research", team_id: null } },
};
const result = seriesBy([first, second], "api_keys", "requests", 1);
expect(result.labels).toEqual(["key-b", "Other"]);
expect(result.data.map((row) => [row[result.keys[0]], row[result.keys[1]]])).toEqual([
[20, 80],
[80, 70],
]);
expect([...bucketTotals(result).values()]).toEqual([100, 150]);
});
});

View file

@ -21,7 +21,10 @@ interface RankedRow extends Record<string, unknown> {
const EMPTY_RANKED = { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 } as const;
/** Sums one breakdown dimension across every day, highest spend first. */
export const rollUpBreakdown = (results: readonly DailyData[], dimension: BreakdownDimension): RankedRow[] => {
export const rollUpBreakdown = (
results: readonly DailyData[],
dimension: BreakdownDimension | "api_keys",
): RankedRow[] => {
const entries = results.flatMap((day) => Object.entries(day.breakdown[dimension] ?? {}));
const totals = entries.reduce<ReadonlyMap<string, RankedRow>>((acc, [key, entry]) => {
const row = acc.get(key) ?? { key, ...EMPTY_RANKED };
@ -117,7 +120,7 @@ export const rankValue = (row: RankedRow, metric: UsageMetric): number => {
*/
export const seriesBy = (
results: readonly DailyData[],
dimension: BreakdownDimension,
dimension: BreakdownDimension | "api_keys",
metric: UsageMetric,
options: number | { top?: number; colorByKey?: ReadonlyMap<string, string> } = 6,
): Series => {

View file

@ -0,0 +1,108 @@
"use client";
import { useMemo, useState } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable, DataTableToolbar } from "@/components/shared/DataTable";
import { PageTabs, PageTabsList, PageTabsTrigger, PageTabsContent } from "@/components/shared/Page";
import type { DeprecationAlert } from "./deprecationAlerts";
import { AlertStatus, effectiveAlertStatus } from "./migrationState";
import RecommendedReplacement from "./RecommendedReplacement";
import { DeprecationModel, DeprecationTiming } from "./DeprecationModel";
import { migrationHref } from "./migrationRoute";
const columns: ColumnDef<DeprecationAlert>[] = [
{
id: "model",
header: "Model",
cell: ({ row }) => (
<div className="py-1">
<DeprecationModel alert={row.original} />
<RecommendedReplacement model={row.original.model.successor_model} />
</div>
),
},
{
id: "deprecation",
header: "Deprecation",
cell: ({ row }) => (
<div className="flex flex-col gap-1">
<DeprecationTiming alert={row.original} />
<span className="text-xs text-muted-foreground">{row.original.model.deprecation_date}</span>
</div>
),
},
{ accessorKey: "requests", header: "Requests · 30d", cell: ({ row }) => row.original.requests.toLocaleString() },
{ accessorKey: "lastUsedDate", header: "Last seen (UTC)" },
{
id: "review",
header: "",
cell: ({ row }) => (
<a href={migrationHref(row.original.usageModel)} className="whitespace-nowrap text-primary hover:underline">
Review impact →
</a>
),
},
];
export default function AlertsInbox({
alerts,
pending,
status,
onStatusChange,
onRefresh,
refreshing,
}: {
alerts: readonly DeprecationAlert[];
pending: boolean;
status: AlertStatus;
onStatusChange: (status: AlertStatus) => void;
onRefresh: () => void;
refreshing: boolean;
}) {
const [search, setSearch] = useState("");
const counts = { open: 0, resolved: 0, dismissed: 0 };
for (const alert of alerts) counts[effectiveAlertStatus(alert)]++;
const filtered = useMemo(
() =>
alerts.filter(
(alert) =>
effectiveAlertStatus(alert) === status && alert.usageModel.toLowerCase().includes(search.toLowerCase()),
),
[alerts, status, search],
);
return (
<PageTabs value={status} onValueChange={onStatusChange} className="gap-4">
<PageTabsList>
{Object.values(AlertStatus).map((value) => (
<PageTabsTrigger key={value} value={value} className="capitalize">
{value}
<span className="ml-1.5 text-xs text-muted-foreground">{counts[value]}</span>
</PageTabsTrigger>
))}
</PageTabsList>
<PageTabsContent value={status}>
<DataTable
data={filtered}
columns={columns}
getRowId={(row) => row.id}
fillHeight
size="compact"
isLoading={pending}
loadingMessage="Loading model deprecations…"
noDataMessage={`No ${status} model deprecations in the last 30 days.`}
toolbar={(table) => (
<DataTableToolbar
table={table}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="Search models…"
onRefresh={onRefresh}
isRefreshing={refreshing}
showViewOptions={false}
/>
)}
/>
</PageTabsContent>
</PageTabs>
);
}

View file

@ -0,0 +1,35 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import { DeprecationModel, DeprecationTiming } from "./DeprecationModel";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { migrationHref } from "@/components/ModelDeprecationAlerts/migrationRoute";
import RecommendedReplacement from "./RecommendedReplacement";
import type { DeprecationAlert } from "./deprecationAlerts";
export default function DeprecationAlertItem({ alert }: { alert: DeprecationAlert }) {
const retired = alert.model.status === "deprecated";
const title = retired
? `${alert.usageModel} is deprecated`
: `${alert.usageModel} retires in ${alert.model.days_until_deprecation} days`;
return (
<article aria-label={title} className="flex flex-col gap-3">
<div className="flex items-start gap-2.5">
<div className="min-w-0 flex-1">
<DeprecationModel alert={alert} />
<p className="mt-1 text-xs">
<DeprecationTiming alert={alert} />
</p>
<p className="mt-1 text-xs text-muted-foreground">
{alert.requests.toLocaleString()} requests in the last 30 days · Last seen {alert.lastUsedDate} UTC
</p>
<RecommendedReplacement model={alert.model.successor_model} />
</div>
</div>
<a className={cn(buttonVariants({ size: "sm" }), "self-start")} href={migrationHref(alert.usageModel)}>
Review migration <ArrowUpRight className="size-3.5" />
</a>
</article>
);
}

View file

@ -0,0 +1,32 @@
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import type { DeprecationAlert } from "./deprecationAlerts";
import { migrationHref } from "./migrationRoute";
export function deprecationProvider(alert: DeprecationAlert): string {
return alert.model.litellm_provider || alert.usageModel.split("/")[0];
}
export function DeprecationModel({ alert }: { alert: DeprecationAlert }) {
const provider = deprecationProvider(alert);
return (
<a href={migrationHref(alert.usageModel)} className="inline-flex items-center gap-2 font-medium hover:underline">
<ProviderLogo provider={provider} className="size-4 shrink-0" />
{alert.usageModel}
</a>
);
}
export function DeprecationTiming({ alert }: { alert: DeprecationAlert }) {
const days = alert.model.days_until_deprecation;
let label = `Deprecates in ${days} ${days === 1 ? "day" : "days"}`;
if (days === 0) label = "Deprecates today";
if (days < 0) label = `Deprecated ${Math.abs(days)} ${days === -1 ? "day" : "days"} ago`;
return (
<span
className={days < 0 ? "text-destructive" : "text-amber-700 dark:text-amber-400"}
title={alert.model.deprecation_date}
>
{label}
</span>
);
}

View file

@ -0,0 +1,335 @@
"use client";
import { useMemo } from "react";
import { useQueryStates } from "nuqs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { ArrowUpRight, Check, Copy, RotateCcw, X } from "lucide-react";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { DataTable, useUrlTableState } from "@/components/shared/DataTable";
import { Button, buttonVariants } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { backgroundFetchClient } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
import { keyDetailHref, modelGroupHref } from "@/utils/entityLinks";
import { copyToClipboard } from "@/utils/dataUtils";
import { AlertStatus, effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import { migrationUrlParsers } from "./migrationRoute";
import type { DeprecationAlert, deprecationUsageRange } from "@/components/ModelDeprecationAlerts/deprecationAlerts";
import RecommendedReplacement from "@/components/ModelDeprecationAlerts/RecommendedReplacement";
import { DeprecationTiming } from "./DeprecationModel";
import UsageTags from "./UsageTags";
import ModelUsageChart from "./ModelUsageChart";
import { affectedTeams } from "./migrationData";
const TABLE_STATE = { sortFields: [], defaultSort: { id: "", desc: false }, defaultPageSize: 50, filterColumns: [] };
type KeyRow = components["schemas"]["KeySpendActivityRow"];
function migrationHeading(alert: DeprecationAlert, status: AlertStatus): string {
if (status === AlertStatus.Resolved) return "Migration marked resolved";
if (status === AlertStatus.Dismissed) return "Model deprecation dismissed";
if (alert.model.status === "deprecated") return "Migration needed: model is deprecated";
return `Migration due ${alert.model.deprecation_date}`;
}
function migrationFollowUp(
alert: DeprecationAlert,
context: { team: string; count: number | undefined; lastSeen: string; url: string },
): string {
const retirement = alert.model.status === "deprecated" ? "retired on" : "retires on";
const keys = context.count === undefined ? "the affected" : context.count;
return `${context.team}: please migrate ${keys} virtual keys using ${alert.usageModel}. This model ${retirement} ${alert.model.deprecation_date}. Last seen ${context.lastSeen} UTC. Please confirm once migration is complete. Affected keys: ${context.url}`;
}
export default function MigrationDetail({
selected,
scope,
range,
isViewOnly,
}: {
selected: DeprecationAlert;
scope: string;
range: ReturnType<typeof deprecationUsageRange>;
isViewOnly: boolean;
}) {
const [params, setParams] = useQueryStates(migrationUrlParsers);
const { data: teams = [] } = useTeams();
const teamNames = useMemo(
() => new Map(teams.map((team) => [team.team_id, team.team_alias || team.team_id])),
[teams],
);
const queryClient = useQueryClient();
const table = useUrlTableState(TABLE_STATE);
const model = selected.usageModel;
const keyQuery = useQuery({
queryKey: ["deprecationImpactKeys", scope, model, params.team, range, table.pagination],
queryFn: async ({ signal }) => {
const query = {
...range,
model,
offset: table.pagination.pageIndex * table.pagination.pageSize,
limit: table.pagination.pageSize,
timezone: 0,
};
if (params.team) {
const { data } = await backgroundFetchClient.GET("/team/daily/activity/aggregated/keys", {
params: { query: { ...query, team_ids: params.team } },
signal,
});
if (!data) throw new Error("Affected keys are unavailable");
return data;
}
const { data } = await backgroundFetchClient.GET("/user/daily/activity/aggregated/keys", {
params: { query },
signal,
});
if (!data) throw new Error("Affected keys are unavailable");
return data;
},
});
const keys = useMemo(() => keyQuery.data?.api_keys.map((key) => key.api_key) ?? [], [keyQuery.data]);
const tagQueryOptions = {
queryKey: ["deprecationUsageTags", scope, model, range, keys],
enabled: keys.length > 0,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const { data } = await backgroundFetchClient.GET("/alerting/model-deprecations/tags", {
params: { query: { ...range, model, api_keys: keys } },
signal,
});
if (!data) throw new Error("Usage tags are unavailable");
return data;
},
staleTime: 5 * 60 * 1000,
};
const tagQuery = useQuery(tagQueryOptions);
const tagsByKey = useMemo(() => {
const tags = new Map<string, string[]>();
for (const usage of tagQuery.data ?? []) {
const existing = tags.get(usage.api_key) ?? [];
existing.push(usage.tag);
tags.set(usage.api_key, existing);
}
return tags;
}, [tagQuery.data]);
const teamQuery = useQuery({
queryKey: ["deprecationImpactTeams", scope, model, range],
queryFn: async ({ signal }) => {
const query = { ...range, model, timezone: 0, api_key_limit: 1 };
const { data } = await backgroundFetchClient.GET("/team/daily/activity/aggregated", {
params: { query },
signal,
});
if (!data) throw new Error("Affected teams are unavailable");
return affectedTeams(data);
},
});
const statusMutation = useMutation({
mutationFn: async (status: AlertStatus) => {
const body = {
model: selected.usageModel,
deprecation_date: selected.model.deprecation_date,
status,
last_seen_date: selected.lastUsedDate,
last_seen_requests: selected.lastDayRequests,
};
const { data } = await backgroundFetchClient.PUT("/alerting/model-deprecations/state", { body });
if (!data) throw new Error("Could not save alert status");
return data;
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["deprecationAlerts"] }),
});
const columns = useMemo<ColumnDef<KeyRow>[]>(
() => [
{
id: "key",
header: "Virtual key",
cell: ({ row }) => (
<a href={keyDetailHref(row.original.api_key)} className="font-medium text-primary hover:underline">
{row.original.metadata.key_alias || "Unnamed key"}
</a>
),
},
{
id: "team",
header: "Team",
cell: ({ row }) =>
teamNames.get(row.original.metadata.team_id ?? "") || row.original.metadata.team_id || "No team",
},
{
id: "owner",
header: "Owner",
cell: ({ row }) => row.original.metadata.user_email || row.original.metadata.user_id || "Not recorded",
},
{
id: "tags",
header: "Usage tags",
cell: ({ row }) => (
<UsageTags
tags={tagsByKey.get(row.original.api_key) ?? []}
loading={tagQuery.isPending}
failed={tagQuery.isError}
/>
),
},
{
id: "requests",
header: "Requests · 30d",
cell: ({ row }) => <span className="tabular-nums">{row.original.metrics.api_requests.toLocaleString()}</span>,
},
{ id: "spend", header: "Spend · 30d", cell: ({ row }) => `$${row.original.metrics.spend.toFixed(2)}` },
],
[teamNames, tagsByKey, tagQuery.isPending, tagQuery.isError],
);
const status = effectiveAlertStatus(selected);
const heading = migrationHeading(selected, status);
const copyFollowUp = () => {
const teamName = params.team ? teamNames.get(params.team) || params.team : "Team owners";
const count = keyQuery.data?.total_api_keys;
const lastSeen = params.team
? teamQuery.data?.find((team) => team.id === params.team)?.lastUsedDate ?? selected.lastUsedDate
: selected.lastUsedDate;
const context = { team: teamName, count, lastSeen, url: window.location.href };
const message = migrationFollowUp(selected, context);
void copyToClipboard(message, "Follow-up copied");
};
return (
<>
<section className="flex flex-wrap items-start justify-between gap-3 rounded-xl border bg-card p-3">
<div>
<span className="rounded-full border px-2 py-0.5 text-xs capitalize">{status}</span>
<h2 className="mt-3 text-lg font-semibold">{heading}</h2>
<p className="mt-1 text-sm text-muted-foreground">
Last seen {selected.lastUsedDate} UTC · {selected.requests.toLocaleString()} requests in the last 30 days
</p>
<RecommendedReplacement model={selected.model.successor_model} />
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={copyFollowUp}>
<Copy />
Copy follow-up
</Button>
{!isViewOnly && (
<>
{status === AlertStatus.Open ? (
<>
<Button
disabled={statusMutation.isPending}
onClick={() => statusMutation.mutate(AlertStatus.Resolved)}
>
<Check />
Mark resolved
</Button>
<Button
variant="ghost"
disabled={statusMutation.isPending}
onClick={() => statusMutation.mutate(AlertStatus.Dismissed)}
>
<X />
Dismiss
</Button>
</>
) : (
<Button disabled={statusMutation.isPending} onClick={() => statusMutation.mutate(AlertStatus.Open)}>
<RotateCcw />
Reopen
</Button>
)}
</>
)}
{modelGroupHref(selected.model.model_name) && (
<a className={buttonVariants({ variant: "outline" })} href={modelGroupHref(selected.model.model_name)}>
Model routing <ArrowUpRight size={14} />
</a>
)}
</div>
</section>
<p className="text-xs text-muted-foreground">
Resolve across all teams once owners confirm migration. New traffic reopens the alert.
</p>
{statusMutation.isError && (
<p role="alert" className="text-destructive">
Could not save alert status. Please retry.
</p>
)}
<ModelUsageChart model={model} team={params.team} scope={scope} range={range} />
<div className="grid shrink-0 grid-cols-3 divide-x rounded-xl border bg-card">
<div className="p-3">
<p className="text-xs text-muted-foreground">{params.team ? "Keys in this team" : "Affected keys"}</p>
<p className="mt-1 text-xl font-semibold tabular-nums">
{keyQuery.data?.total_api_keys.toLocaleString() ?? "—"}
</p>
</div>
<div className="p-3">
<p className="text-xs text-muted-foreground">Teams using this model</p>
<p className="mt-1 text-xl font-semibold tabular-nums">{teamQuery.data?.length ?? "—"}</p>
</div>
<div className="p-3">
<p className="text-xs text-muted-foreground">Deprecation</p>
<p className="mt-1 text-lg font-semibold">
<DeprecationTiming alert={selected} />
</p>
</div>
</div>
<section className="flex h-[480px] shrink-0 flex-col overflow-hidden rounded-xl border">
<div className="flex flex-wrap items-center justify-between gap-3 border-b p-4">
<div>
<h2 className="font-medium">
Affected keys{" "}
<span className="ml-1 text-muted-foreground">
{keyQuery.data?.total_api_keys.toLocaleString() ?? "…"}
</span>
</h2>
<p className="mt-1 text-xs text-muted-foreground">
Keys and owners that used this model in the last 30 days. Tags show actual usage with this model.
</p>
</div>
<Select
value={params.team ?? "__all"}
onValueChange={(value) => {
void setParams({ team: value === "__all" ? null : value });
table.onPaginationChange({ ...table.pagination, pageIndex: 0 });
}}
>
<SelectTrigger className="min-w-52" aria-label="Filter affected keys by team">
<SelectValue>{params.team ? teamNames.get(params.team) || params.team : "All teams"}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all">All teams</SelectItem>
{(teamQuery.data ?? []).map((team) => (
<SelectItem key={team.id} value={team.id}>
{teamNames.get(team.id) || team.id} · {team.keys} keys
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{keyQuery.isError ? (
<div className="p-5">
<p>Could not load affected keys.</p>
<Button variant="outline" onClick={() => void keyQuery.refetch()}>
Retry
</Button>
</div>
) : (
<DataTable
columns={columns}
data={keyQuery.data?.api_keys ?? []}
isLoading={keyQuery.isPending}
paginationMode="server"
pagination={table.pagination}
onPaginationChange={table.onPaginationChange}
rowCount={keyQuery.data?.total_api_keys ?? 0}
fillHeight
size="compact"
getRowId={(row) => row.api_key}
/>
)}
</section>
{teamQuery.isError && (
<p role="alert">Could not load team filters. The keys table still includes all matching teams.</p>
)}
</>
);
}

View file

@ -0,0 +1,124 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import ModelAlertsPage from "@/app/(dashboard)/alerts/page";
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => ({ data: [{ team_id: "support", team_alias: "Support" }] }),
}));
vi.mock("@/components/ModelDeprecationAlerts/useDeprecationAlerts", () => ({
useDeprecationAlerts: () => ({
scope: "test",
range: { start_date: "2026-10-06", end_date: "2026-11-04" },
isPending: false,
isError: false,
data: [
{
id: "retirement",
usageModel: "openai/gpt-4-turbo",
requests: 5000,
lastUsedDate: "2026-11-04",
lastDayRequests: 20,
model: {
model_name: "support-model",
deprecation_date: "2026-10-23",
days_until_deprecation: -12,
status: "deprecated",
successor_model: "replacement-model",
},
},
],
}),
}));
const calls: { path: string; query: URLSearchParams; method: string; body: unknown }[] = [];
function apiResponse(data: unknown) {
return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } });
}
describe("model migration impact", () => {
beforeEach(() => {
testQueryClient.clear();
calls.length = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const request = input instanceof Request ? input : new Request(input);
const url = new URL(request.url);
const call: (typeof calls)[number] = {
path: url.pathname,
query: url.searchParams,
method: request.method,
body: request.method === "PUT" ? await request.clone().json() : null,
};
calls.push(call);
if (url.pathname === "/alerting/model-deprecations/tags") return apiResponse([]);
if (request.method === "PUT" && url.pathname === "/alerting/model-deprecations/state")
return apiResponse({ status: "resolved" });
if (url.pathname.endsWith("/aggregated/keys")) {
const offset = Number(url.searchParams.get("offset") || 0);
const rows = Array.from({ length: 50 }, (_, i) => ({
api_key: `reporting-${offset + i}`,
metadata: { key_alias: `support-key-${offset + i}`, team_id: "support", user_email: "owner@example.com" },
metrics: { api_requests: 100, spend: 1 },
}));
const page = { api_keys: rows, total_api_keys: 120, offset, limit: 50 };
return apiResponse(page);
}
if (url.pathname.endsWith("/team/daily/activity/aggregated"))
return apiResponse({ results: [], metadata: { entity_total_api_keys: {} } });
return apiResponse({});
});
});
afterEach(() => vi.restoreAllMocks());
it("should render the inbox with last seen and the recommendation supplied by the API", () => {
renderWithProviders(<ModelAlertsPage />);
expect(screen.getByRole("tab", { name: "Model deprecations", exact: true })).toBeInTheDocument();
expect(screen.getByText("replacement-model")).toBeInTheDocument();
expect(screen.getByText("2026-11-04")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Review impact →" })).toHaveAttribute(
"href",
"/ui/alerts?type=model-deprecations&model=openai%2Fgpt-4-turbo",
);
});
it("should page through all affected keys instead of showing a top-ten sample", async () => {
const user = userEvent.setup();
renderWithProviders(<ModelAlertsPage />, { searchParams: { model: "openai/gpt-4-turbo" } });
expect(await screen.findByRole("link", { name: "support-key-0" })).toBeInTheDocument();
expect(screen.getByText(/Showing 1-50 of 120/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Go to next page", exact: true }));
await screen.findByRole("link", { name: "support-key-50" });
expect(
calls.some(
(call) =>
call.path.endsWith("/aggregated/keys") &&
call.query.get("offset") === "50" &&
call.query.get("model") === "openai/gpt-4-turbo",
),
).toBe(true);
});
it("should resolve the whole model using its global latest-day snapshot even in a team-filtered view", async () => {
const user = userEvent.setup();
renderWithProviders(<ModelAlertsPage />, {
searchParams: { model: "openai/gpt-4-turbo", team: "support" },
});
expect(await screen.findByRole("link", { name: "support-key-0" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Mark resolved", exact: true }));
const expectedAcknowledgement = {
model: "openai/gpt-4-turbo",
deprecation_date: "2026-10-23",
status: "resolved",
last_seen_date: "2026-11-04",
last_seen_requests: 20,
};
await waitFor(() =>
expect(
calls.find((call) => call.method === "PUT" && call.path === "/alerting/model-deprecations/state")?.body,
).toEqual(expectedAcknowledgement),
);
expect(
calls.some(
(call) => call.path === "/team/daily/activity/aggregated/keys" && call.query.get("team_ids") === "support",
),
).toBe(true);
});
});

View file

@ -0,0 +1,76 @@
"use client";
import { useQueryStates } from "nuqs";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { deprecationProvider } from "./DeprecationModel";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Page } from "@/components/shared/Page";
import { Button, buttonVariants } from "@/components/ui/button";
import { useDeprecationAlerts } from "@/components/ModelDeprecationAlerts/useDeprecationAlerts";
import { hasCapability } from "@/utils/capabilities";
import { migrationUrlParsers, migrationHref } from "./migrationRoute";
import AlertsInbox from "./AlertsInbox";
import MigrationDetail from "./MigrationDetail";
export default function ModelDeprecationsPanel() {
const { accessToken, userRole, isViewOnly } = useAuthorized();
const allowed = hasCapability(userRole, "viewGlobalSpend");
const [params, setParams] = useQueryStates(migrationUrlParsers);
const alerts = useDeprecationAlerts(accessToken);
const selected = alerts.data?.find((alert) => alert.usageModel === params.model);
const loadingDetail = !alerts.isError && Boolean(params.model) && alerts.isPending;
const detailReady = !alerts.isError && Boolean(params.model) && !alerts.isPending;
const missingDetail = detailReady && !selected;
if (!allowed)
return (
<Page className="h-full gap-3 overflow-y-auto p-0 sm:p-0">
<h1 className="text-xl font-semibold">Model deprecations</h1>
<p>Gateway admin access is required to view affected keys and teams.</p>
</Page>
);
return (
<Page className="h-full gap-3 overflow-y-auto p-0 sm:p-0">
{params.model && (
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
{selected && <ProviderLogo provider={deprecationProvider(selected)} className="size-6 shrink-0" />}
<code className="rounded-md border bg-muted/50 px-2.5 py-1.5 font-mono text-base font-medium break-all">
{params.model}
</code>
</h1>
</div>
<a className={buttonVariants({ variant: "outline" })} href={migrationHref()}>
← Model deprecations
</a>
</header>
)}
{alerts.isError ? (
<div role="alert">
<p>Could not load alerts. No alert status has been changed.</p>
<Button onClick={() => void alerts.refetch()}>Retry</Button>
</div>
) : null}
{!alerts.isError && !params.model && (
<AlertsInbox
alerts={alerts.data ?? []}
pending={alerts.isPending}
onRefresh={() => void alerts.refetch()}
refreshing={alerts.isFetching}
status={params.state}
onStatusChange={(state) => void setParams({ state })}
/>
)}
{loadingDetail && <p role="status">Loading alert…</p>}
{missingDetail && <p>No matching model alert was found in the last 30 days.</p>}
{!alerts.isError && selected && (
<MigrationDetail
selected={selected}
scope={alerts.scope}
range={alerts.range}
isViewOnly={Boolean(isViewOnly)}
/>
)}
</Page>
);
}

View file

@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import ModelUsageChart from "./ModelUsageChart";
vi.mock("@/components/shared/charts", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/shared/charts")>()),
StackedUsageChart: ({ data, labels }: { data: unknown; labels: string[] }) => (
<div role="img" aria-label="Daily key usage chart">
{JSON.stringify({ data, labels })}
</div>
),
}));
const range = { start_date: "2026-10-07", end_date: "2026-11-05" };
function mockActivity() {
return vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(
JSON.stringify({
results: [
{
date: "2026-11-03",
metrics: { api_requests: 100, total_tokens: 1000, spend: 10 },
breakdown: {
api_keys: {
"key-a": {
metrics: { api_requests: 30, spend: 3, total_tokens: 300 },
metadata: { key_alias: "Support key" },
},
"key-b": {
metrics: { api_requests: 20, spend: 2, total_tokens: 200 },
metadata: { key_alias: "Research key" },
},
},
},
},
],
}),
{ headers: { "Content-Type": "application/json" } },
),
);
}
describe("model daily usage", () => {
afterEach(() => vi.restoreAllMocks());
it("should render the model's daily requests stacked by key and preserve remaining usage", async () => {
const fetch = mockActivity();
renderWithProviders(
<ModelUsageChart model="anthropic/retiring-model" team={null} scope="test-global" range={range} />,
);
const chart = await screen.findByRole("img", { name: "Daily key usage chart" });
expect(chart).toHaveTextContent('"labels":["Support key","Research key","Other"]');
expect(chart).toHaveTextContent('"s0":30,"s1":20,"s2":50');
const request = fetch.mock.calls[0][0] as Request;
expect(new URL(request.url).searchParams.get("model")).toBe("anthropic/retiring-model");
});
it("should apply the selected team and show requests without metric controls", async () => {
const fetch = mockActivity();
renderWithProviders(
<ModelUsageChart model="anthropic/retiring-model" team="support" scope="test-team" range={range} />,
);
await screen.findByRole("img", { name: "Daily key usage chart" });
const request = fetch.mock.calls[0][0] as Request;
expect(new URL(request.url).pathname).toBe("/team/daily/activity/aggregated");
expect(new URL(request.url).searchParams.get("team_ids")).toBe("support");
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
expect(screen.getByRole("img", { name: "Daily key usage chart" })).toHaveTextContent('"s0":30,"s1":20,"s2":50');
});
it("should keep all keys in the stacks while allowing the key labels to expand", async () => {
const fetch = mockActivity();
const keys = Object.fromEntries(
Array.from({ length: 12 }, (_, index) => [
`key-${index}`,
{
metrics: { api_requests: 10, spend: 1, total_tokens: 100 },
metadata: { key_alias: `Key ${index + 1}` },
},
]),
);
const response = {
results: [
{
date: "2026-11-03",
metrics: { api_requests: 120, spend: 12, total_tokens: 1200 },
breakdown: { api_keys: keys },
},
],
};
fetch.mockResolvedValueOnce(
new Response(JSON.stringify(response), { headers: { "Content-Type": "application/json" } }),
);
const user = userEvent.setup();
renderWithProviders(
<ModelUsageChart model="anthropic/retiring-model" team={null} scope="test-legend" range={range} />,
);
expect(await screen.findByRole("img", { name: "Daily key usage chart" })).toHaveTextContent('"s11":10');
expect(screen.queryByText("Key 12", { exact: true })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show all key labels" }));
expect(screen.getByText("Key 12", { exact: true })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show fewer key labels" }));
expect(screen.queryByText("Key 12", { exact: true })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,127 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { StackedUsageChart } from "@/components/shared/charts";
import { Button } from "@/components/ui/button";
import { backgroundFetchClient } from "@/lib/http/api";
import { toDailyData } from "@/components/UsagePage/dailyActivityApi";
import { Panel, ChartSkeleton } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import { seriesBy } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import type { deprecationUsageRange } from "./deprecationAlerts";
import { dailyModelRequests } from "./migrationData";
const formatDate = (date: string) =>
new Date(`${date}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
export default function ModelUsageChart({
model,
team,
scope,
range,
}: {
model: string;
team: string | null;
scope: string;
range: ReturnType<typeof deprecationUsageRange>;
}) {
const [legendExpanded, setLegendExpanded] = useState(false);
const activityOptions = {
queryKey: ["deprecationDailyUsage", scope, model, team, range],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const query = { ...range, model, api_key_limit: 100, timezone: 0 };
if (team) {
const { data } = await backgroundFetchClient.GET("/team/daily/activity/aggregated", {
params: { query: { ...query, team_ids: team } },
signal,
});
if (!data) throw new Error("Daily model usage is unavailable");
return data;
}
const { data } = await backgroundFetchClient.GET("/user/daily/activity/aggregated", {
params: { query },
signal,
});
if (!data) throw new Error("Daily model usage is unavailable");
return data;
},
staleTime: 5 * 60 * 1000,
refetchInterval: 5 * 60 * 1000,
};
const activity = useQuery(activityOptions);
const daily = useMemo(() => dailyModelRequests(activity.data?.results ?? [], range), [activity.data, range]);
const totals = useMemo(() => new Map(daily.map((day) => [day.date, day.requests])), [daily]);
const series = useMemo(() => {
const days = activity.data ? toDailyData(activity.data) : [];
const aliases = new Map(
days.flatMap((day) =>
Object.entries(day.breakdown.api_keys).map(
([id, entry]) => [id, entry.metadata.key_alias || `Key · ${id.slice(0, 6)}`] as const,
),
),
);
const result = seriesBy(days, "api_keys", "requests", 100);
const byDate = new Map(result.data.map((day) => [day.date, day]));
const zeros = Object.fromEntries(result.keys.map((key) => [key, 0]));
result.data = daily.map((day) => byDate.get(day.date) ?? { date: day.date, label: formatDate(day.date), ...zeros });
result.labels = result.labels.map((label) => aliases.get(label) || label);
return result;
}, [activity.data, daily]);
function renderContent() {
if (activity.isPending) return <ChartSkeleton className="h-64" />;
if (activity.isError)
return (
<div role="alert" className="py-6 text-sm">
Could not load daily requests.{" "}
<Button variant="outline" size="sm" onClick={() => void activity.refetch()}>
Retry
</Button>
</div>
);
return (
<>
<StackedUsageChart
data={series.data}
series={series.keys}
labels={series.labels}
colors={series.colors}
xKey="date"
xLabel={formatDate}
format={(value) => value.toLocaleString()}
totalFor={(date) => totals.get(date)}
className="h-64"
/>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-muted-foreground">
{series.labels.slice(0, legendExpanded ? undefined : 8).map((label, index) => (
<span key={series.keys[index]} className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm" style={{ backgroundColor: series.colors[index] }} />
{label}
</span>
))}
</div>
{series.labels.length > 8 && (
<button
type="button"
className="mt-2 self-start text-xs text-primary hover:underline"
aria-expanded={legendExpanded}
onClick={() => setLegendExpanded(!legendExpanded)}
>
{legendExpanded ? "Show fewer key labels" : "Show all key labels"}
</button>
)}
</>
);
}
return (
<Panel
title="Daily requests"
subtitle={`Requests to this model, stacked by key · ${team ? "Selected team" : "All teams"}`}
className="shrink-0"
>
{renderContent()}
<p className="mt-2 text-xs text-muted-foreground">
Last 30 days · UTC · Includes zero-usage days. Today is partial.
</p>
</Panel>
);
}

View file

@ -0,0 +1,7 @@
export default function RecommendedReplacement({ model }: { model: string | null | undefined }) {
return (
<p className="mt-2 text-xs text-muted-foreground">
Recommended new model: <span className="font-medium text-foreground">{model || "Not provided"}</span>
</p>
);
}

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import UsageTags from "./UsageTags";
describe("model usage tags", () => {
it("should render tags used with the model", () => {
render(<UsageTags tags={["production"]} loading={false} failed={false} />);
expect(screen.getByText("production")).toBeInTheDocument();
});
it("should show five tags and let the user expand and collapse the rest", async () => {
const user = userEvent.setup();
render(<UsageTags tags={["one", "two", "three", "four", "five", "six", "seven"]} loading={false} failed={false} />);
expect(screen.queryByText("six")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show 2 more usage tags" }));
expect(screen.getByText("six")).toBeInTheDocument();
expect(screen.getByText("seven")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show fewer usage tags" }));
expect(screen.queryByText("six")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
export default function UsageTags({
tags,
loading,
failed,
}: {
tags: readonly string[];
loading: boolean;
failed: boolean;
}) {
const [expanded, setExpanded] = useState(false);
if (loading) return <span className="text-xs text-muted-foreground">Loading tags…</span>;
if (failed) return <span className="text-xs text-muted-foreground">Tags unavailable</span>;
if (tags.length === 0) return <span className="text-xs text-muted-foreground">No recorded tags</span>;
const visible = expanded ? tags : tags.slice(0, 5);
return (
<div className="flex max-w-72 flex-wrap items-center gap-1">
{visible.map((tag) => (
<Badge key={tag} variant="secondary" className="max-w-full whitespace-normal break-all font-normal">
{tag}
</Badge>
))}
{tags.length > 5 && (
<button
type="button"
className="text-xs text-primary hover:underline"
aria-expanded={expanded}
aria-label={expanded ? "Show fewer usage tags" : `Show ${tags.length - 5} more usage tags`}
onClick={() => setExpanded(!expanded)}
>
{expanded ? "Show less" : `+${tags.length - 5} more`}
</button>
)}
</div>
);
}

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import type { components } from "@/lib/http/schema";
import { deprecationUsageRange, usedDeprecationAlerts } from "./deprecationAlerts";
type Model = components["schemas"]["ModelDeprecationInfo"];
type Day = components["schemas"]["DailySpendData"];
const model: Model = {
model_name: "support",
litellm_model: "anthropic/claude-sonnet-4-5",
deprecation_date: "2026-11-30",
days_until_deprecation: 26,
status: "imminent",
};
// Use the API's defaults for the unrelated aggregate metrics.
const metrics: components["schemas"]["SpendMetrics"] = {
spend: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
function day(date: string, count: number, name = model.litellm_model!): Day {
return { date, metrics, breakdown: { models: { [name]: { metrics: { ...metrics, api_requests: count } } } } };
}
describe("usedDeprecationAlerts", () => {
it("should aggregate requests and report the latest day of actual model usage", () => {
const result = usedDeprecationAlerts([model], [day("2026-11-04", 12), day("2026-10-20", 8)]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ usageModel: model.litellm_model, requests: 20, lastUsedDate: "2026-11-04" });
});
it("should omit configured models without observed requests", () => {
expect(usedDeprecationAlerts([model], [])).toEqual([]);
expect(usedDeprecationAlerts([model], [day("2026-11-04", 0)])).toEqual([]);
});
it("should avoid attributing healthy deployments in the same group to a deprecated model", () => {
const activity = day("2026-11-04", 100, "anthropic/claude-sonnet-4-6");
activity.breakdown!.model_groups = { support: { metrics: { ...metrics, api_requests: 100 } } };
expect(usedDeprecationAlerts([model], [activity])).toEqual([]);
});
it("should match provider-qualified usage when the cost map names an unqualified model", () => {
const entry = { ...model, litellm_model: "claude-sonnet-4-5", litellm_provider: "anthropic" };
const result = usedDeprecationAlerts([entry], [day("2026-11-04", 12)]);
expect(result[0]).toMatchObject({ usageModel: "anthropic/claude-sonnet-4-5", requests: 12 });
});
it("should keep usage for different providers separate", () => {
const entry = { ...model, litellm_provider: "anthropic" };
expect(usedDeprecationAlerts([entry], [day("2026-11-04", 12, "bedrock/claude-sonnet-4-5")])).toEqual([]);
});
it("should deduplicate deployments and put the earliest retirement first", () => {
const retired = { ...model, deprecation_date: "2026-10-23", status: "deprecated" as const };
const result = usedDeprecationAlerts([model, retired, model], [day("2026-11-04", 10)]);
expect(result.map((alert) => alert.model.deprecation_date)).toEqual(["2026-10-23", "2026-11-30"]);
});
it("should give an escalated alert a new dismissal identity", () => {
const activity = [day("2026-11-04", 10)];
const imminent = usedDeprecationAlerts([model], activity)[0];
const retired = usedDeprecationAlerts([{ ...model, status: "deprecated" }], activity)[0];
expect(retired.id).not.toBe(imminent.id);
});
it("should use the public model name when underlying model metadata is unavailable", () => {
const result = usedDeprecationAlerts([{ ...model, litellm_model: null }], [day("2026-11-04", 10, "support")]);
expect(result[0].requests).toBe(10);
});
});
describe("deprecationUsageRange", () => {
it("should include exactly 30 UTC calendar dates across month boundaries", () => {
expect(deprecationUsageRange(new Date("2026-11-04T23:00:00Z"))).toEqual({
start_date: "2026-10-06",
end_date: "2026-11-04",
});
});
});

View file

@ -0,0 +1,61 @@
import type { components } from "@/lib/http/schema";
type ModelDeprecation = components["schemas"]["ModelDeprecationInfo"];
type DailySpendData = components["schemas"]["DailySpendData"];
export interface DeprecationAlert {
id: string;
model: ModelDeprecation;
usageModel: string;
requests: number;
lastUsedDate: string;
lastDayRequests: number;
state?: components["schemas"]["DeprecationAlertState"];
}
export function deprecationUsageRange(today: Date) {
const end = new Date(today);
const start = new Date(today);
start.setUTCDate(start.getUTCDate() - 29);
return { start_date: start.toISOString().slice(0, 10), end_date: end.toISOString().slice(0, 10) };
}
/** Match actual model usage, rather than model-group totals that may include healthy deployments. */
export function usedDeprecationAlerts(
models: readonly ModelDeprecation[],
days: readonly DailySpendData[],
): DeprecationAlert[] {
const alerts: DeprecationAlert[] = [];
const seen = new Set<string>();
for (const model of models) {
const baseModel = model.litellm_model ?? model.model_name;
const candidates = new Set([baseModel]);
if (model.litellm_provider && !baseModel.startsWith(`${model.litellm_provider}/`)) {
candidates.add(`${model.litellm_provider}/${baseModel}`);
}
for (const usageModel of candidates) {
const id = JSON.stringify([usageModel, model.deprecation_date, model.status]);
if (seen.has(id)) continue;
seen.add(id);
let requests = 0;
let lastUsedDate = "";
let lastDayRequests = 0;
for (const day of days) {
const metrics = day.breakdown?.models?.[usageModel]?.metrics;
if (!metrics || metrics.api_requests <= 0) continue;
requests += metrics.api_requests;
if (day.date > lastUsedDate) {
lastUsedDate = day.date;
lastDayRequests = metrics.api_requests;
} else if (day.date === lastUsedDate) {
lastDayRequests += metrics.api_requests;
}
}
if (requests > 0) {
const alert = { id, model, usageModel, requests, lastUsedDate, lastDayRequests };
alerts.push(alert);
}
}
}
return alerts.sort((a, b) => a.model.deprecation_date.localeCompare(b.model.deprecation_date));
}

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { components } from "@/lib/http/schema";
import { dailyModelRequests } from "./migrationData";
type Day = components["schemas"]["DailySpendData"];
function day(date: string, api_requests: number): Day {
return {
date,
metrics: {
api_requests,
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
successful_requests: api_requests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
};
}
describe("model request trend", () => {
it("should include zero-usage days after the model stops receiving traffic", () => {
expect(dailyModelRequests([day("2026-11-03", 120)], { start_date: "2026-11-03", end_date: "2026-11-05" })).toEqual([
{ date: "2026-11-03", requests: 120 },
{ date: "2026-11-04", requests: 0 },
{ date: "2026-11-05", requests: 0 },
]);
});
it("should keep dates chronological across month boundaries and preserve daily totals", () => {
expect(
dailyModelRequests([day("2026-11-01", 5), day("2026-10-31", 50)], {
start_date: "2026-10-31",
end_date: "2026-11-02",
}),
).toEqual([
{ date: "2026-10-31", requests: 50 },
{ date: "2026-11-01", requests: 5 },
{ date: "2026-11-02", requests: 0 },
]);
});
});

View file

@ -0,0 +1,49 @@
import type { components } from "@/lib/http/schema";
type Activity = components["schemas"]["SpendAnalyticsPaginatedResponse"];
export interface AffectedTeam {
id: string;
requests: number;
keys: number;
lastUsedDate: string;
}
export function affectedTeams(activity: Activity): AffectedTeam[] {
const teams = new Map<string, AffectedTeam>();
for (const day of activity.results ?? []) {
for (const [id, entry] of Object.entries(day.breakdown?.entities ?? {})) {
if (entry.metrics.api_requests <= 0) continue;
const team = teams.get(id) ?? {
id,
requests: 0,
keys: activity.metadata?.entity_total_api_keys?.[id] ?? 0,
lastUsedDate: "",
};
team.requests += entry.metrics.api_requests;
if (day.date > team.lastUsedDate) team.lastUsedDate = day.date;
teams.set(id, team);
}
}
return [...teams.values()].sort((a, b) => b.requests - a.requests);
}
export interface DailyModelRequests extends Record<string, unknown> {
date: string;
requests: number;
}
/** Include quiet days so a completed migration remains visible as zero traffic. */
export function dailyModelRequests(
days: readonly components["schemas"]["DailySpendData"][],
range: { start_date: string; end_date: string },
): DailyModelRequests[] {
const counts = new Map<string, number>();
for (const day of days) counts.set(day.date, (counts.get(day.date) ?? 0) + day.metrics.api_requests);
const result: DailyModelRequests[] = [];
const end = new Date(`${range.end_date}T00:00:00Z`);
for (const day = new Date(`${range.start_date}T00:00:00Z`); day <= end; day.setUTCDate(day.getUTCDate() + 1)) {
const date = day.toISOString().slice(0, 10);
result.push({ date, requests: counts.get(date) ?? 0 });
}
return result;
}

View file

@ -0,0 +1,14 @@
import { parseAsString, parseAsStringEnum } from "nuqs";
import { AlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import { uiHref } from "@/utils/uiHref";
export const migrationUrlParsers = {
model: parseAsString,
team: parseAsString,
state: parseAsStringEnum<AlertStatus>(Object.values(AlertStatus)).withDefault(AlertStatus.Open),
};
export function migrationHref(model?: string) {
const base = `${uiHref("alerts")}?type=model-deprecations`;
return model ? `${base}&model=${encodeURIComponent(model)}` : base;
}

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { AlertStatus, effectiveAlertStatus } from "./migrationState";
import type { DeprecationAlert } from "./deprecationAlerts";
const alert: DeprecationAlert = {
id: "model",
usageModel: "openai/gpt-4-turbo",
requests: 1000,
lastUsedDate: "2026-11-04",
lastDayRequests: 10,
model: { model_name: "support", deprecation_date: "2026-10-23", days_until_deprecation: -12, status: "deprecated" },
};
const state = {
model: alert.usageModel,
deprecation_date: "2026-10-23",
status: "resolved" as const,
last_seen_date: "2026-11-04",
last_seen_requests: 10,
};
describe("effectiveAlertStatus", () => {
it("should keep a resolved migration closed when only its historic traffic remains", () => {
expect(effectiveAlertStatus({ ...alert, state })).toBe(AlertStatus.Resolved);
});
it("should reopen on additional requests in the same UTC day", () => {
expect(effectiveAlertStatus({ ...alert, lastDayRequests: 11, state })).toBe(AlertStatus.Open);
});
it("should reopen when the model is used on a later day", () => {
expect(effectiveAlertStatus({ ...alert, lastUsedDate: "2026-11-05", state })).toBe(AlertStatus.Open);
});
it("should keep acknowledgement when old requests roll out of the 30-day window", () => {
expect(effectiveAlertStatus({ ...alert, requests: 900, state })).toBe(AlertStatus.Resolved);
});
it("should allow a shared dismissed state and reopen it on fresh traffic", () => {
const dismissed = { ...state, status: "dismissed" as const };
expect(effectiveAlertStatus({ ...alert, state: dismissed })).toBe(AlertStatus.Dismissed);
expect(effectiveAlertStatus({ ...alert, lastDayRequests: 11, state: dismissed })).toBe(AlertStatus.Open);
});
});

View file

@ -0,0 +1,17 @@
import type { DeprecationAlert } from "./deprecationAlerts";
export enum AlertStatus {
Open = "open",
Resolved = "resolved",
Dismissed = "dismissed",
}
export function effectiveAlertStatus(alert: DeprecationAlert): AlertStatus {
const state = alert.state;
if (!state || state.status === "open") return AlertStatus.Open;
if (!state.last_seen_date || alert.lastUsedDate > state.last_seen_date) return AlertStatus.Open;
if (alert.lastUsedDate === state.last_seen_date && alert.lastDayRequests > state.last_seen_requests)
return AlertStatus.Open;
if (state.status === "resolved") return AlertStatus.Resolved;
return AlertStatus.Dismissed;
}

View file

@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, testQueryClient } from "../../../tests/test-utils";
import { backgroundFetchClient } from "@/lib/http/api";
import { useDeprecationAlerts } from "./useDeprecationAlerts";
import { useCurrentUser } from "@/app/(dashboard)/hooks/currentUser/useCurrentUser";
vi.mock("@/lib/http/api", () => ({ backgroundFetchClient: { GET: vi.fn() } }));
vi.mock("@/app/(dashboard)/hooks/currentUser/useCurrentUser", () => ({ useCurrentUser: vi.fn() }));
function AlertsHost() {
const { data, isError, fetchStatus } = useDeprecationAlerts("test-session");
if (isError) return <div>Unavailable</div>;
if (fetchStatus !== "idle") return <div>Loading</div>;
return <div>{data?.length ?? 0} alerts</div>;
}
describe("deprecation alert API wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
});
it("should avoid querying gateway-wide usage for non-admins", () => {
vi.mocked(useCurrentUser).mockReturnValue({
data: { user_id: "internal", user_role: "internal_user" },
} as ReturnType<typeof useCurrentUser>);
renderWithProviders(<AlertsHost />);
expect(screen.getByText("0 alerts")).toBeInTheDocument();
expect(backgroundFetchClient.GET).not.toHaveBeenCalled();
});
it("should skip the usage query when no configured models need attention", async () => {
vi.mocked(useCurrentUser).mockReturnValue({ data: { user_id: "admin", user_role: "proxy_admin" } } as ReturnType<
typeof useCurrentUser
>);
vi.mocked(backgroundFetchClient.GET).mockResolvedValue({
data: { deprecated: [], imminent: [], upcoming: [] },
} as never);
renderWithProviders(<AlertsHost />);
expect(await screen.findByText("0 alerts")).toBeInTheDocument();
expect(backgroundFetchClient.GET).toHaveBeenCalledTimes(1);
expect(backgroundFetchClient.GET).toHaveBeenCalledWith(
"/model/deprecations",
expect.objectContaining({ params: { query: { warn_within_days: 30 } } }),
);
});
it("should load acknowledgement state from the alerting API for models with observed usage", async () => {
vi.mocked(useCurrentUser).mockReturnValue({ data: { user_id: "admin", user_role: "proxy_admin" } } as ReturnType<
typeof useCurrentUser
>);
const model = {
model_name: "support",
litellm_model: "anthropic/claude-sonnet-4-5",
deprecation_date: "2026-11-30",
status: "imminent",
};
const schedule = { deprecated: [], imminent: [model], upcoming: [] };
const activity = {
results: [
{ date: "2026-11-04", breakdown: { models: { [model.litellm_model]: { metrics: { api_requests: 10 } } } } },
],
};
vi.mocked(backgroundFetchClient.GET)
.mockResolvedValueOnce({ data: schedule } as never)
.mockResolvedValueOnce({ data: activity } as never)
.mockResolvedValueOnce({ data: { status: "open" } } as never);
renderWithProviders(<AlertsHost />);
expect(await screen.findByText("1 alerts")).toBeInTheDocument();
expect(backgroundFetchClient.GET).toHaveBeenLastCalledWith(
"/alerting/model-deprecations/state",
expect.objectContaining({
params: { query: { model: model.litellm_model, deprecation_date: model.deprecation_date } },
}),
);
});
});

View file

@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { useCurrentUser } from "@/app/(dashboard)/hooks/currentUser/useCurrentUser";
import { backgroundFetchClient } from "@/lib/http/api";
import { getProxyBaseUrl } from "@/components/networking";
import { hasCapability } from "@/utils/capabilities";
import { deprecationUsageRange, usedDeprecationAlerts } from "./deprecationAlerts";
const POLL_INTERVAL = 5 * 60 * 1000;
export function useDeprecationAlerts(accessToken: string | null) {
const { data: currentUser } = useCurrentUser(accessToken);
const userId = currentUser?.user_id;
const enabled = Boolean(accessToken && userId && hasCapability(currentUser?.user_role, "viewGlobalSpend"));
const scope = JSON.stringify([getProxyBaseUrl(), userId]);
const range = deprecationUsageRange(new Date());
const options = {
queryKey: ["deprecationAlerts", scope, range],
enabled,
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const { data: schedule } = await backgroundFetchClient.GET("/model/deprecations", {
params: { query: { warn_within_days: 30 } },
signal,
});
if (!schedule) throw new Error("Model deprecation information is unavailable");
const models = [...(schedule.deprecated ?? []), ...(schedule.imminent ?? [])];
if (models.length === 0) return [];
const { data: activity } = await backgroundFetchClient.GET("/user/daily/activity/aggregated", {
params: { query: { ...range, timezone: 0, api_key_limit: 1 } },
signal,
});
if (!activity) throw new Error("Model usage is unavailable");
const alerts = usedDeprecationAlerts(models, activity.results ?? []);
return Promise.all(
alerts.map(async (alert) => {
const { data: state } = await backgroundFetchClient.GET("/alerting/model-deprecations/state", {
params: { query: { model: alert.usageModel, deprecation_date: alert.model.deprecation_date } },
signal,
});
if (!state) throw new Error("Alert acknowledgement state is unavailable");
alert.state = state;
return alert;
}),
);
},
staleTime: POLL_INTERVAL,
refetchInterval: POLL_INTERVAL,
retry: false,
};
const query = useQuery(options);
return { ...query, data: enabled ? query.data : undefined, scope, range };
}

View file

@ -5,6 +5,14 @@ import React from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/ModelDeprecationAlerts/useDeprecationAlerts", () => ({
useDeprecationAlerts: () => ({
data: [],
scope: "test",
range: { start_date: "2026-10-06", end_date: "2026-11-04" },
}),
}));
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));
@ -173,3 +181,77 @@ describe("NotificationsBell", () => {
expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("Your current version is v1.102.0");
});
});
const DEPRECATION = {
id: "sonnet:2026-11-30:imminent",
usageModel: "anthropic/claude-sonnet-4-5",
requests: 18432,
lastUsedDate: "2026-11-04",
lastDayRequests: 123,
model: {
model_name: "support",
litellm_model: "anthropic/claude-sonnet-4-5",
deprecation_date: "2026-11-30",
days_until_deprecation: 26,
status: "imminent" as const,
successor_model: "recommended-model",
},
};
describe("model deprecation notifications", () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it("should show last seen and link to the migration workspace and open alerts inbox", async () => {
const user = userEvent.setup();
renderWithProviders(
<NotificationsBellView currentVersion={null} latestRelease={null} deprecationAlerts={[DEPRECATION]} />,
);
await user.click(screen.getByRole("button", { name: /1 model deprecation alert/ }));
expect(screen.getByRole("article", { name: /retires in 26 days/ })).toHaveTextContent("Last seen 2026-11-04 UTC");
expect(screen.getByRole("link", { name: "Review migration" })).toHaveAttribute(
"href",
"/ui/alerts?type=model-deprecations&model=anthropic%2Fclaude-sonnet-4-5",
);
expect(screen.getByRole("link", { name: "View all 1" })).toHaveAttribute(
"href",
"/ui/alerts?type=model-deprecations",
);
expect(screen.getByText("recommended-model")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Dismiss", exact: true })).not.toBeInTheDocument();
});
it("should clear the warning when the alert has a shared resolved acknowledgement", () => {
const state = {
model: DEPRECATION.usageModel,
deprecation_date: "2026-11-30",
status: "resolved" as const,
last_seen_date: "2026-11-04",
last_seen_requests: 123,
};
renderWithProviders(
<NotificationsBellView
currentVersion={null}
latestRelease={null}
deprecationAlerts={[{ ...DEPRECATION, state }]}
/>,
);
expect(bell(1)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /model deprecation alert/ })).not.toBeInTheDocument();
});
it("should reopen a resolved alert when more requests arrive on the last-seen day", () => {
const state = {
model: DEPRECATION.usageModel,
deprecation_date: "2026-11-30",
status: "resolved" as const,
last_seen_date: "2026-11-04",
last_seen_requests: 122,
};
renderWithProviders(
<NotificationsBellView
currentVersion={null}
latestRelease={null}
deprecationAlerts={[{ ...DEPRECATION, state }]}
/>,
);
expect(screen.getByRole("button", { name: /1 model deprecation alert/ })).toBeInTheDocument();
});
});

View file

@ -17,7 +17,12 @@ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/compone
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/cva.config";
import { isNewerVersion } from "@/utils/versionUtils";
import { ArrowUpRight, Bell } from "lucide-react";
import { AlertTriangle, ArrowUpRight, Bell } from "lucide-react";
import DeprecationAlertItem from "@/components/ModelDeprecationAlerts/DeprecationAlertItem";
import { type DeprecationAlert } from "@/components/ModelDeprecationAlerts/deprecationAlerts";
import { useDeprecationAlerts } from "@/components/ModelDeprecationAlerts/useDeprecationAlerts";
import { AlertStatus, effectiveAlertStatus } from "@/components/ModelDeprecationAlerts/migrationState";
import { migrationHref } from "@/components/ModelDeprecationAlerts/migrationRoute";
import React, { Fragment } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
@ -102,6 +107,7 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme
};
interface NotificationsBellViewProps {
deprecationAlerts?: readonly DeprecationAlert[];
deploymentHealthLink?: React.ReactNode;
currentVersion: string | null | undefined;
latestRelease: LatestReleaseInfo | null | undefined;
@ -111,25 +117,39 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
currentVersion,
latestRelease,
deploymentHealthLink,
deprecationAlerts,
}) => {
const { t } = useTranslation();
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
const openAlerts = (deprecationAlerts ?? []).filter((alert) => effectiveAlertStatus(alert) === AlertStatus.Open);
const openDeprecations = openAlerts.length;
let label =
unreadCount > 0
? t("shell.notifications.bell.triggerUnread", { unread: unreadCount })
: t("shell.notifications.bell.trigger");
if (openDeprecations > 0) {
label = `Notifications, ${openDeprecations} model deprecation ${openDeprecations === 1 ? "alert needs" : "alerts need"} attention`;
}
return (
<Popover>
<PopoverTrigger
render={<Button variant="ghost" size="icon" className="text-muted-foreground" />}
aria-label={
unreadCount > 0
? t("shell.notifications.bell.triggerUnread", { unread: unreadCount })
: t("shell.notifications.bell.trigger")
}
aria-label={label}
>
<span className="relative inline-flex">
<Bell className="size-4" aria-hidden />
{unreadCount > 0 ? <Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden /> : null}
<Bell className={cn("size-4", openDeprecations > 0 && "text-amber-700")} aria-hidden />
{openDeprecations > 0 ? (
<span className="absolute -top-2 -right-2 rounded-full bg-background p-px text-amber-600" aria-hidden>
<AlertTriangle className="size-3" fill="currentColor" stroke="var(--background)" strokeWidth={1.5} />
</span>
) : null}
{openDeprecations === 0 && unreadCount > 0 ? (
<Badge className="absolute -top-0.5 -right-1 size-1.5 p-0" aria-hidden />
) : null}
</span>
</PopoverTrigger>
<PopoverContent
@ -142,6 +162,33 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
</PopoverTitle>
<div className="max-h-[min(32rem,calc(100dvh-10rem))] overflow-y-auto">
<div className="space-y-4 p-4">
{openAlerts.length > 0 && (
<>
<div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold">Model deprecations</p>
<a className="text-xs text-primary hover:underline" href={migrationHref()}>
View all {openDeprecations}
</a>
</div>
<p className="mt-1 text-xs text-muted-foreground">Model migrations across your keys and teams.</p>
</div>
{openAlerts.slice(0, 3).map((alert) => (
<Fragment key={alert.id}>
<Separator />
<DeprecationAlertItem alert={alert} />
</Fragment>
))}
<Separator />
<p className="text-xs font-medium text-muted-foreground">Announcements</p>
</>
)}
{deprecationAlerts && openAlerts.length === 0 && (
<a href={migrationHref()} className="text-sm font-medium text-primary hover:underline">
View model deprecations
</a>
)}
{announcements.map((announcement, index) => (
<Fragment key={announcement.readKey}>
{index > 0 ? <Separator /> : null}
@ -162,10 +209,12 @@ export const NotificationsBell: React.FC<{ accessToken: string | null; deploymen
}) => {
const { data: healthData } = useHealthReadinessDetails(accessToken);
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
const { data: deprecationAlerts } = useDeprecationAlerts(accessToken);
return (
<NotificationsBellView
currentVersion={healthData?.litellm_version}
latestRelease={latestRelease}
deprecationAlerts={deprecationAlerts}
deploymentHealthLink={deploymentHealthLink}
/>
);

View file

@ -693,6 +693,7 @@ describe("Sidebar (leftnav)", () => {
expectHref("Playground", "/ui/playground");
expectHref("Models + Endpoints", "/ui/models-and-endpoints");
expectHref("Usage", "/ui/usage");
expectHref("Alerts", "/ui/alerts");
expectHref("API Reference", "/ui/api-reference");
});

View file

@ -299,6 +299,13 @@ const menuGroups: MenuGroup[] = [
roles: [...all_admin_roles, ...internalUserRoles],
label: "Usage",
},
{
key: "alerts",
page: "alerts",
label: "Alerts",
icon: <Bell {...ICON} />,
roles: rolesWithCapability("viewGlobalSpend"),
},
{
key: "model-insights",
page: "model-insights",

View file

@ -1,10 +1,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { 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";
@ -46,10 +45,6 @@ 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(),
@ -82,8 +77,6 @@ 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;
@ -202,10 +195,6 @@ describe("ModelInfoView", () => {
mockCredentialCreateCall.mockResolvedValue({});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
@ -216,70 +205,6 @@ 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,6 +1,5 @@
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";
@ -825,7 +824,7 @@ export default function ModelInfoView({
accessToken={accessToken}
modelId={modelId}
onUpdated={() => {
void invalidateModelQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
}}
/>
)}

View file

@ -20,6 +20,7 @@ export const pageDescriptions: Record<string, string> = {
"tool-policies": "Configure tool use policies and permissions",
"vector-stores": "Manage vector databases for embeddings",
new_usage: "View usage analytics and metrics",
alerts: "Review and resolve model deprecation alerts across keys and teams",
"cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
"model-insights": "Model Leaderboard: compare usage, spend, tokens, and task mix across this gateway",
logs: "Access request and response logs",

View file

@ -944,6 +944,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/alerting/model-deprecations/state": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Deprecation Alert State */
get: operations["get_deprecation_alert_state_alerting_model_deprecations_state_get"];
/** Update Deprecation Alert State */
put: operations["update_deprecation_alert_state_alerting_model_deprecations_state_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/alerting/model-deprecations/tags": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Deprecation Usage Tags
* @description Distinct tags observed with this model for a page of affected reporting key IDs.
*/
get: operations["get_deprecation_usage_tags_alerting_model_deprecations_tags_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/alerting/settings": {
parameters: {
query?: never;
@ -32376,6 +32414,59 @@ export interface components {
/** Warnings */
warnings: components["schemas"]["DeploymentWarning"][];
};
/** DeprecationAlertState */
DeprecationAlertState: {
/**
* Deprecation Date
* Format: date
*/
deprecation_date: string;
/** Last Seen Date */
last_seen_date?: string | null;
/**
* Last Seen Requests
* @default 0
*/
last_seen_requests: number;
/** Model */
model: string;
/** @default open */
status: components["schemas"]["DeprecationAlertStatus"];
/** Updated At */
updated_at?: string | null;
/** Updated By */
updated_by?: string | null;
};
/**
* DeprecationAlertStatus
* @enum {string}
*/
DeprecationAlertStatus: "open" | "resolved" | "dismissed";
/** DeprecationAlertUpdate */
DeprecationAlertUpdate: {
/**
* Deprecation Date
* Format: date
*/
deprecation_date: string;
/**
* Last Seen Date
* Format: date
*/
last_seen_date: string;
/** Last Seen Requests */
last_seen_requests: number;
/** Model */
model: string;
status: components["schemas"]["DeprecationAlertStatus"];
};
/** DeprecationTagUsage */
DeprecationTagUsage: {
/** Api Key */
api_key: string;
/** Tag */
tag: string;
};
/** DiscoverAgentRequest */
DiscoverAgentRequest: {
/**
@ -53157,6 +53248,105 @@ export interface operations {
};
};
};
get_deprecation_alert_state_alerting_model_deprecations_state_get: {
parameters: {
query: {
model: string;
deprecation_date: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeprecationAlertState"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_deprecation_alert_state_alerting_model_deprecations_state_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DeprecationAlertUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeprecationAlertState"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_deprecation_usage_tags_alerting_model_deprecations_tags_get: {
parameters: {
query: {
model: string;
api_keys: string[];
start_date: string;
end_date: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeprecationTagUsage"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
alerting_settings_alerting_settings_get: {
parameters: {
query?: never;