mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
5c60824024
commit
d2b2e76374
58 changed files with 2180 additions and 1589 deletions
1
litellm/proxy/alerting_endpoints/__init__.py
Normal file
1
litellm/proxy/alerting_endpoints/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""HTTP endpoints for gateway alerts."""
|
||||
109
litellm/proxy/alerting_endpoints/model_deprecation_alerts.py
Normal file
109
litellm/proxy/alerting_endpoints/model_deprecation_alerts.py
Normal 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),
|
||||
)
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
19
ui/litellm-dashboard/src/app/(dashboard)/alerts/page.tsx
Normal file
19
ui/litellm-dashboard/src/app/(dashboard)/alerts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
]);
|
||||
};
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
190
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
190
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue