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