mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(ui): give each Models + Endpoints tab its own path (#34327)
* feat(ui): give each Models + Endpoints tab its own path * refactor(ui): decompose Models + Endpoints into per-tab pages with URL-driven detail Dissolve the 488-line ModelsAndEndpointsView monolith into one page per tab under the models-and-endpoints route, with a persistent layout owning the header, cost banner, tab bar and refresh. Each tab page owns only its own state; shared lists come from a small useModelDashboardData hook. Replace the stateful model/team drill-in (setSelectedModelId/setSelectedTeamId full-page takeover) with real URL navigation: ?model=<id> and ?team=<id> render ModelInfoView/TeamInfoView from the layout, so a model or team detail view is now shareable, bookmarkable and back-button friendly. Removes the empty placeholder pages from the first commit. Swap the tab bar off phased-out tremor onto antd Tabs. * fix(ui): render model tab panels standalone instead of Tremor TabPanel AllModelsTab, ModelRetrySettingsTab and PriceDataManagementTab rooted their render in a Tremor <TabPanel>, which only renders inside a Tremor <TabGroup>. After the decomposition these panels live under antd Tabs / as route pages with no such ancestor, so All Models (and the other two) rendered blank. Root them in a plain container instead. The existing component tests mocked @tremor/react (stubbing TabPanel to render children), which hid this; add a regression test that renders with real Tremor and asserts the content is visible standalone. Also type visibleSlugs/TAB_LABELS with the canonical ModelTabSlug so a tab added without a matching label is a compile error. * fix(ui): make model/team drill-in navigation work under the /ui static mount The drill-in close (Back to Models) and open were no-ops: the dashboard is a static export served under /ui, a prefix the Next router (basePath "") does not know, so a router.push to the current pathname with only the query changed is deduped and never re-renders. Drive the ?model=/?team= overlay via real browser navigation (window.location) so open and close reliably work; verified live. Also address review feedback: gate the tab-permission redirect on teams/uiSettings having loaded so a team admin hard-loading /add is not bounced to the base before their membership resolves, and memoize getProviderFromModel on modelCostMapData so the health tab's provider labels refresh when the cost map loads. * fix(ui): use window.location.replace for the tab-permission redirect router.replace is unreliable under the /ui static mount (same class of Next-router issue that broke the drill-in back button), so the forbidden-tab redirect could fail to fire. Use window.location.replace, which keeps the no-history semantics of a permission redirect and is deterministic. Redirect stays gated on teams/uiSettings having loaded. * fix(ui): drive model/team drill-in with history.pushState for client-side nav Switch the ?model=/?team= overlay navigation from window.location.assign to window.history.pushState, which Next's App Router observes. This keeps navigation client-side (no full page reload, React Query cache preserved) while still working for the same-path query-only change that router.push cannot do under the /ui static mount. Open, close (Back to Models) and browser Back are all verified in the built UI. Adds unit coverage for the open/close/read behavior.
This commit is contained in:
parent
ba86889f11
commit
0b0d59d62e
23 changed files with 912 additions and 715 deletions
|
|
@ -1,212 +0,0 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, fireEvent, render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsView from "./ModelsAndEndpointsView";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
|
||||
// Minimal stubs to avoid Next.js router and network usage during render
|
||||
vi.mock("@/components/networking", () => ({
|
||||
credentialListCall: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||
modelInfoCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
modelCostMap: vi.fn().mockResolvedValue({}),
|
||||
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: {} }),
|
||||
getCallbacksCall: vi.fn().mockResolvedValue({ router_settings: {} }),
|
||||
setCallbacksCall: vi.fn().mockResolvedValue(undefined),
|
||||
getUiSettings: vi.fn().mockResolvedValue({ values: {} }),
|
||||
latestHealthChecksCall: vi.fn().mockResolvedValue({ latest_health_checks: {} }),
|
||||
getModelCostMapReloadStatus: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/components/ModelAnalyticsTab/ModelAnalyticsTab", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/add_model/add_auto_router_tab", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/add_model/AddModelForm", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
|
||||
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
|
||||
default: (props: { all_models_on_proxy?: string[] }) => {
|
||||
mockHealthCheckComponent(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
||||
default: () => ({
|
||||
teams: [],
|
||||
setTeams: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseModelsInfo = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
||||
useModelsInfo: () => mockUseModelsInfo(),
|
||||
}));
|
||||
|
||||
const mockUseUISettings = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => mockUseUISettings(),
|
||||
}));
|
||||
|
||||
const mockUseModelCostMap = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
|
||||
useModelCostMap: () => mockUseModelCostMap(),
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => mockUseAuthorized(),
|
||||
}));
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
|
||||
describe("ModelsAndEndpointsView", () => {
|
||||
beforeEach(() => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
mockUseUISettings.mockReturnValue({
|
||||
data: { values: {} },
|
||||
});
|
||||
mockUseModelCostMap.mockReturnValue({
|
||||
data: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Admin",
|
||||
userId: "123",
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
it("should render the models and endpoints view", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Cost Optimization feedback banner by default", async () => {
|
||||
localStorageMock.clear();
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide Cost Optimization feedback banner when dismiss button is clicked and persist to localStorage", async () => {
|
||||
localStorageMock.clear();
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText, queryByText, container } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for banner to appear
|
||||
expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
|
||||
// Find and click dismiss button (X button)
|
||||
const dismissButton = container.querySelector('button[aria-label="Dismiss banner"]');
|
||||
expect(dismissButton).not.toBeNull();
|
||||
fireEvent.click(dismissButton!);
|
||||
|
||||
// Banner should be hidden
|
||||
expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument();
|
||||
|
||||
// LocalStorage should be updated
|
||||
expect(localStorageMock.getItem("hideCostOptimizationFeedbackBanner")).toBe("true");
|
||||
});
|
||||
|
||||
it("should keep Cost Optimization feedback banner hidden across remounts once dismissed", async () => {
|
||||
// Set localStorage to hide banner
|
||||
localStorageMock.setItem("hideCostOptimizationFeedbackBanner", "true");
|
||||
const queryClient = createQueryClient();
|
||||
const { findByText, queryByText } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Wait for component to render
|
||||
await findByText("Model Management", {}, { timeout: 10000 });
|
||||
|
||||
// Banner should not be visible
|
||||
expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => {
|
||||
mockHealthCheckComponent.mockClear();
|
||||
const modelDataWithIds = {
|
||||
data: [
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
|
||||
],
|
||||
};
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: { data: modelDataWithIds.data },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
const { getByRole } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const healthStatusTab = getByRole("tab", { name: "Health Status" });
|
||||
await act(async () => {
|
||||
healthStatusTab.click();
|
||||
});
|
||||
|
||||
expect(mockHealthCheckComponent).toHaveBeenCalled();
|
||||
const healthCheckProps = mockHealthCheckComponent.mock.calls[0][0];
|
||||
expect(healthCheckProps.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
|
||||
expect(healthCheckProps.all_models_on_proxy).not.toContain("gpt-4");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,487 +0,0 @@
|
|||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
|
||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
|
||||
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
|
||||
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { transformModelData } from "./utils/modelDataTransformer";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import type { UploadProps } from "antd";
|
||||
import { Form } from "antd";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import AddModelTab from "../../../components/add_model/add_model_tab";
|
||||
import HealthCheckComponent from "../../../components/model_dashboard/HealthCheckComponent";
|
||||
import ModelGroupAliasSettings from "../../../components/model_group_alias_settings";
|
||||
import ModelInfoView from "../../../components/model_info_view";
|
||||
import NotificationsManager from "../../../components/molecules/notifications_manager";
|
||||
import PassThroughSettings from "../../../components/PassThroughSettings/PassThroughSettings";
|
||||
import TeamInfoView from "../../../components/team/TeamInfo";
|
||||
import useAuthorized from "../hooks/useAuthorized";
|
||||
|
||||
interface ModelDashboardProps {
|
||||
premiumUser: boolean;
|
||||
teams: Team[] | null;
|
||||
}
|
||||
|
||||
interface RetryPolicyObject {
|
||||
[key: string]: { [retryPolicyKey: string]: number } | undefined;
|
||||
}
|
||||
|
||||
interface GlobalRetryPolicyObject {
|
||||
[retryPolicyKey: string]: number;
|
||||
}
|
||||
|
||||
interface RouterSettings {
|
||||
model_group_retry_policy?: RetryPolicyObject | null;
|
||||
retry_policy?: GlobalRetryPolicyObject | null;
|
||||
num_retries?: number | null;
|
||||
model_group_alias?: { [key: string]: string } | null;
|
||||
}
|
||||
|
||||
const HEALTH_PAGE_SIZE = 50;
|
||||
|
||||
const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, teams }) => {
|
||||
const { accessToken, token, userRole, userId: userID } = useAuthorized();
|
||||
const [addModelForm] = Form.useForm();
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
const [providerModels, setProviderModels] = useState<Array<string>>([]);
|
||||
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
|
||||
const [retryScope, setRetryScope] = useState<string | null>("global");
|
||||
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
|
||||
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
|
||||
const [defaultRetry, setDefaultRetry] = useState<number>(0);
|
||||
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState<boolean>(false);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||
const [healthPagination, setHealthPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: HEALTH_PAGE_SIZE,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo();
|
||||
const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo(
|
||||
healthPagination.pageIndex + 1,
|
||||
healthPagination.pageSize,
|
||||
);
|
||||
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
|
||||
const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials();
|
||||
const credentialsList = credentialsResponse?.credentials || [];
|
||||
const { data: uiSettings, isLoading: isLoadingUISettings } = useUISettings();
|
||||
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
|
||||
|
||||
const availableModelGroups = useMemo(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
const allModelGroups = new Set<string>();
|
||||
for (const model of modelDataResponse.data) {
|
||||
allModelGroups.add(model.model_name);
|
||||
}
|
||||
return Array.from(allModelGroups).sort();
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const availableModelAccessGroups = useMemo(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
const allModelAccessGroups = new Set<string>();
|
||||
for (const model of modelDataResponse.data) {
|
||||
const modelInfo = model.model_info;
|
||||
if (modelInfo?.access_groups) {
|
||||
for (const group of modelInfo.access_groups) {
|
||||
allModelAccessGroups.add(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(allModelAccessGroups);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const allModelsOnProxy = useMemo<string[]>(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
return modelDataResponse.data.map((model: any) => model.model_name);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const healthModelIdsOnProxy = useMemo<string[]>(() => {
|
||||
if (!healthModelDataResponse?.data) return [];
|
||||
return healthModelDataResponse.data
|
||||
.map((model: any) => model.model_info?.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id));
|
||||
}, [healthModelDataResponse?.data]);
|
||||
|
||||
const getProviderFromModel = (model: string) => {
|
||||
if (modelCostMapData !== null && modelCostMapData !== undefined) {
|
||||
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
|
||||
return modelCostMapData[model]["litellm_provider"];
|
||||
}
|
||||
}
|
||||
return "openai";
|
||||
};
|
||||
|
||||
const processedModelData = useMemo(() => {
|
||||
if (!modelDataResponse?.data) return { data: [] };
|
||||
return transformModelData(modelDataResponse, getProviderFromModel);
|
||||
}, [modelDataResponse?.data, getProviderFromModel]);
|
||||
|
||||
const processedHealthModelData = useMemo(() => {
|
||||
if (!healthModelDataResponse?.data) return { data: [] };
|
||||
return transformModelData(healthModelDataResponse, getProviderFromModel);
|
||||
}, [healthModelDataResponse?.data, getProviderFromModel]);
|
||||
|
||||
const healthRowCount = healthModelDataResponse?.total_count ?? 0;
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
// Hide tab if user is NOT a proxy admin AND (internal user with setting enabled OR not a team admin)
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
|
||||
const setProviderModelsFn = (provider: Providers) => {
|
||||
const _providerModels = getProviderModels(provider, modelCostMapData);
|
||||
setProviderModels(_providerModels);
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
name: "file",
|
||||
accept: ".json",
|
||||
pastable: false,
|
||||
beforeUpload: (file) => {
|
||||
if (file.type === "application/json") {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target) {
|
||||
const jsonStr = e.target.result as string;
|
||||
addModelForm.setFieldsValue({ vertex_credentials: jsonStr });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onChange(info) {
|
||||
if (info.file.status === "done") {
|
||||
NotificationsManager.success(`${info.file.name} file uploaded successfully`);
|
||||
} else if (info.file.status === "error") {
|
||||
NotificationsManager.fromBackend(`${info.file.name} file upload failed.`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
const currentDate = new Date();
|
||||
setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
setHealthPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
refetchModels();
|
||||
};
|
||||
|
||||
const fetchRouterSettings = useCallback(async (): Promise<RouterSettings | null> => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const routerSettingsInfo = await getCallbacksCall(accessToken, userID, userRole);
|
||||
return routerSettingsInfo.router_settings;
|
||||
} catch (error) {
|
||||
console.error("Error fetching model data:", error);
|
||||
return null;
|
||||
}
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
const applyRouterSettings = useCallback((routerSettings: RouterSettings) => {
|
||||
setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null);
|
||||
setGlobalRetryPolicy(routerSettings.retry_policy ?? null);
|
||||
setDefaultRetry(routerSettings.num_retries ?? 2);
|
||||
setModelGroupAlias(routerSettings.model_group_alias || {});
|
||||
}, []);
|
||||
|
||||
const loadRetrySettings = useCallback(async () => {
|
||||
const routerSettings = await fetchRouterSettings();
|
||||
if (routerSettings) {
|
||||
applyRouterSettings(routerSettings);
|
||||
}
|
||||
}, [fetchRouterSettings, applyRouterSettings]);
|
||||
|
||||
const handleSaveRetrySettings = () => {
|
||||
updateRetryPolicy.mutate(
|
||||
{
|
||||
retry_policy: globalRetryPolicy,
|
||||
model_group_retry_policy: modelGroupRetryPolicy,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("Retry settings saved successfully");
|
||||
loadRetrySettings();
|
||||
},
|
||||
onError: () => {
|
||||
NotificationsManager.fromBackend("Failed to save retry settings");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !token || !userRole || !userID || !modelDataResponse) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const routerSettings = await fetchRouterSettings();
|
||||
if (active && routerSettings) {
|
||||
applyRouterSettings(routerSettings);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [accessToken, token, userRole, userID, modelDataResponse, fetchRouterSettings, applyRouterSettings]);
|
||||
|
||||
const isLoading = isLoadingModels || isLoadingModelCostMap || isLoadingCredentials || isLoadingUISettings;
|
||||
|
||||
// Admin Viewer can view all models read-only — page render proceeds; the
|
||||
// individual write-action tabs (Add Model, LLM Credentials, etc.) are
|
||||
// gated separately below.
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await addModelForm.validateFields();
|
||||
await handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick);
|
||||
} catch (error: any) {
|
||||
const errorMessages =
|
||||
error.errorFields
|
||||
?.map((field: any) => {
|
||||
return `${field.name.join(".")}: ${field.errors.join(", ")}`;
|
||||
})
|
||||
.join(" | ") || "Unknown validation error";
|
||||
NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`);
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(Providers).find((key) => (Providers as { [index: string]: any })[key] === selectedProvider);
|
||||
// If a team is selected, render TeamInfoView in full page layout
|
||||
if (selectedTeamId) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<TeamInfoView
|
||||
teamId={selectedTeamId}
|
||||
onClose={() => setSelectedTeamId(null)}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin"}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={handleRefreshClick}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
{/* Model Management Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
{!all_admin_roles.includes(userRole) ? (
|
||||
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Optimization Feedback Banner */}
|
||||
<CostOptimizationFeedbackBanner />
|
||||
{selectedModelId && !isLoading ? (
|
||||
<ModelInfoView
|
||||
modelId={selectedModelId}
|
||||
onClose={() => {
|
||||
setSelectedModelId(null);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onModelUpdate={(updatedModel) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
handleRefreshClick();
|
||||
}}
|
||||
modelAccessGroups={availableModelAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
// Build a single source-of-truth list of {tab, panel} pairs.
|
||||
// Conditionally-hidden tabs (e.g. "Add Model" for non-admin) get
|
||||
// filtered out as a unit so tab indices and panel indices can
|
||||
// never drift apart — Tremor's TabList and TabPanels filter
|
||||
// falsy children inconsistently, which previously caused
|
||||
// "click LLM Credentials, see nothing" for Admin Viewer.
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
const visibleTabs: Array<{ tab: React.ReactElement; panel: React.ReactElement }> = [
|
||||
{
|
||||
tab: <Tab key="all-models">{isAdmin ? "All Models" : "Your Models"}</Tab>,
|
||||
panel: (
|
||||
<AllModelsTab
|
||||
key="all-models"
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
if (!shouldHideAddModelTab) {
|
||||
visibleTabs.push({
|
||||
tab: <Tab key="add-model">Add Model</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="add-model" className="h-full">
|
||||
<AddModelTab
|
||||
form={addModelForm}
|
||||
handleOk={handleOk}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
providerModels={providerModels}
|
||||
setProviderModelsFn={setProviderModelsFn}
|
||||
getPlaceholder={getPlaceholder}
|
||||
uploadProps={uploadProps}
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams}
|
||||
credentials={credentialsList}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isAdmin) {
|
||||
visibleTabs.push(
|
||||
{
|
||||
tab: <Tab key="llm-credentials">LLM Credentials</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="llm-credentials">
|
||||
<CredentialsPanel uploadProps={uploadProps} />
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="pass-through">Pass-Through Endpoints</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="pass-through">
|
||||
<PassThroughSettings
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="health-status">Health Status</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="health-status">
|
||||
<HealthCheckComponent
|
||||
accessToken={accessToken}
|
||||
modelData={processedHealthModelData}
|
||||
all_models_on_proxy={healthModelIdsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
teams={teams}
|
||||
isLoading={isLoadingHealthModels}
|
||||
pagination={healthPagination}
|
||||
onPaginationChange={setHealthPagination}
|
||||
rowCount={healthRowCount}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="model-retry-settings">Model Retry Settings</Tab>,
|
||||
panel: (
|
||||
<ModelRetrySettingsTab
|
||||
key="model-retry-settings"
|
||||
selectedModelGroup={retryScope}
|
||||
setSelectedModelGroup={setRetryScope}
|
||||
availableModelGroups={availableModelGroups}
|
||||
globalRetryPolicy={globalRetryPolicy}
|
||||
setGlobalRetryPolicy={setGlobalRetryPolicy}
|
||||
defaultRetry={defaultRetry}
|
||||
modelGroupRetryPolicy={modelGroupRetryPolicy}
|
||||
setModelGroupRetryPolicy={setModelGroupRetryPolicy}
|
||||
handleSaveRetrySettings={handleSaveRetrySettings}
|
||||
isSaving={updateRetryPolicy.isPending}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="model-group-alias">Model Group Alias</Tab>,
|
||||
panel: (
|
||||
<TabPanel key="model-group-alias">
|
||||
<ModelGroupAliasSettings
|
||||
accessToken={accessToken}
|
||||
initialModelGroupAlias={modelGroupAlias}
|
||||
onAliasUpdate={setModelGroupAlias}
|
||||
/>
|
||||
</TabPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
tab: <Tab key="price-data-reload">Price Data Reload</Tab>,
|
||||
panel: <PriceDataManagementTab key="price-data-reload" />,
|
||||
},
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TabGroup
|
||||
index={selectedTabIndex}
|
||||
onIndexChange={setSelectedTabIndex}
|
||||
className="gap-2 h-[75vh] w-full "
|
||||
>
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">{visibleTabs.map((t) => t.tab)}</div>
|
||||
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<Icon
|
||||
icon={RefreshIcon}
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="cursor-pointer"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>{visibleTabs.map((t) => t.panel)}</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsAndEndpointsView;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { Form } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import AddModelTab from "@/components/add_model/add_model_tab";
|
||||
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
|
||||
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function AddModelPage() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
const { data: credentialsResponse } = useCredentials();
|
||||
const { data: teams } = useTeams();
|
||||
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
|
||||
const [providerModels, setProviderModels] = useState<string[]>([]);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await handleAddModelSubmit(values, accessToken, form, refresh);
|
||||
} catch (error: any) {
|
||||
const errorMessages =
|
||||
error.errorFields?.map((field: any) => `${field.name.join(".")}: ${field.errors.join(", ")}`).join(" | ") ||
|
||||
"Unknown validation error";
|
||||
NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModelTab
|
||||
form={form}
|
||||
handleOk={handleOk}
|
||||
selectedProvider={selectedProvider}
|
||||
setSelectedProvider={setSelectedProvider}
|
||||
providerModels={providerModels}
|
||||
setProviderModelsFn={(provider) => setProviderModels(getProviderModels(provider, modelCostMapData))}
|
||||
getPlaceholder={getPlaceholder}
|
||||
uploadProps={vertexCredentialsUploadProps(form)}
|
||||
showAdvancedSettings={showAdvancedSettings}
|
||||
setShowAdvancedSettings={setShowAdvancedSettings}
|
||||
teams={teams ?? null}
|
||||
credentials={credentialsResponse?.credentials || []}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
|
|||
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import { PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Grid, TabPanel } from "@tremor/react";
|
||||
import { Grid } from "@tremor/react";
|
||||
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
|
||||
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
|
|
@ -232,7 +232,7 @@ const AllModelsTab = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<div className="w-full">
|
||||
<Grid>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="bg-white rounded-lg shadow-sm">
|
||||
|
|
@ -600,7 +600,7 @@ const AllModelsTab = ({
|
|||
onCancel={() => setIsModelSettingsModalVisible(false)}
|
||||
onSuccess={() => setIsModelSettingsModalVisible(false)}
|
||||
/>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Select, SelectItem, TabPanel, Text, Title } from "@tremor/react";
|
||||
import { Button, Select, SelectItem, Text, Title } from "@tremor/react";
|
||||
import { InputNumber } from "antd";
|
||||
import React from "react";
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ const ModelRetrySettingsTab = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<Text>Retry Policy Scope:</Text>
|
||||
|
|
@ -132,7 +132,7 @@ const ModelRetrySettingsTab = ({
|
|||
<Button className="mt-6 mr-8" onClick={handleSaveRetrySettings} loading={isSaving} disabled={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import PriceDataManagementTab from "./PriceDataManagementTab";
|
||||
|
||||
// Deliberately do NOT mock @tremor/react. These tab components render standalone
|
||||
// (inside antd Tabs / directly as a route page), no longer inside a Tremor
|
||||
// <TabGroup>. A Tremor <TabPanel> root renders nothing without that context, so
|
||||
// this asserts the component's content is visible on its own — reverting the root
|
||||
// back to <TabPanel> makes the title disappear and fails this test.
|
||||
vi.mock("@/components/price_data_reload", () => ({ default: () => <div>reload</div> }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
|
||||
useModelCostMap: () => ({ refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe("PriceDataManagementTab", () => {
|
||||
it("renders its content standalone, without a Tremor TabGroup ancestor", () => {
|
||||
const { getByText } = render(<PriceDataManagementTab />);
|
||||
expect(getByText("Price Data Management")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { TabPanel, Text, Title } from "@tremor/react";
|
||||
import { Text, Title } from "@tremor/react";
|
||||
import PriceDataReload from "@/components/price_data_reload";
|
||||
import React from "react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -9,7 +9,7 @@ const PriceDataManagementTab = () => {
|
|||
const { refetch: refetchModelCostMap } = useModelCostMap();
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<div>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<Title>Price Data Management</Title>
|
||||
|
|
@ -28,7 +28,7 @@ const PriceDataManagementTab = () => {
|
|||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useModelDetailRouting } from "./detailNavigation";
|
||||
|
||||
// The detail overlay is driven by ?model=/?team= on the current path. Under the
|
||||
// /ui static mount a router.push to the same path (query-only change) is a no-op,
|
||||
// so navigation goes through history.pushState (client-side, no full reload).
|
||||
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
|
||||
|
||||
describe("useModelDetailRouting", () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/");
|
||||
});
|
||||
|
||||
it("openModel sets ?model= via history.pushState (no full navigation)", () => {
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
act(() => result.current.openModel("abc-1"));
|
||||
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("model=abc-1"));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("openTeam sets ?team= and drops any model param", () => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1");
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
act(() => result.current.openTeam("team-9"));
|
||||
const url = spy.mock.calls.at(-1)?.[2] as string;
|
||||
expect(url).toContain("team=team-9");
|
||||
expect(url).not.toContain("model=");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("close removes both model and team params", () => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1");
|
||||
const spy = vi.spyOn(window.history, "pushState");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
act(() => result.current.close());
|
||||
const url = spy.mock.calls.at(-1)?.[2] as string;
|
||||
expect(url).not.toContain("model=");
|
||||
expect(url).not.toContain("team=");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("reads modelId and teamId from the query string", () => {
|
||||
window.history.pushState(null, "", "/models-and-endpoints/?model=xyz");
|
||||
const { result } = renderHook(() => useModelDetailRouting());
|
||||
expect(result.current.modelId).toBe("xyz");
|
||||
expect(result.current.teamId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export interface ModelDetailRouting {
|
||||
modelId: string | null;
|
||||
teamId: string | null;
|
||||
openModel: (id: string) => void;
|
||||
openTeam: (id: string) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function navigateWithParams(mutate: (params: URLSearchParams) => void): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
mutate(params);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
window.history.pushState(null, "", url);
|
||||
}
|
||||
|
||||
export function useModelDetailRouting(): ModelDetailRouting {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const openModel = useCallback((id: string) => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("team");
|
||||
params.set("model", id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openTeam = useCallback((id: string) => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("model");
|
||||
params.set("team", id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
navigateWithParams((params) => {
|
||||
params.delete("model");
|
||||
params.delete("team");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
modelId: searchParams?.get("model") ?? null,
|
||||
teamId: searchParams?.get("team") ?? null,
|
||||
openModel,
|
||||
openTeam,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import HealthStatusPage from "./page";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/models-and-endpoints/health",
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
|
||||
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
|
||||
default: (props: { all_models_on_proxy?: string[] }) => {
|
||||
mockHealthCheckComponent(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer", () => ({
|
||||
transformModelData: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const mockUseModelsInfo = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelsInfo: () => mockUseModelsInfo() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) }));
|
||||
|
||||
describe("HealthStatusPage", () => {
|
||||
beforeEach(() => {
|
||||
mockHealthCheckComponent.mockClear();
|
||||
});
|
||||
|
||||
it("passes deployment ids (not model names) to HealthCheckComponent as all_models_on_proxy", () => {
|
||||
mockUseModelsInfo.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
|
||||
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
|
||||
],
|
||||
total_count: 2,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<HealthStatusPage />);
|
||||
|
||||
expect(mockHealthCheckComponent).toHaveBeenCalled();
|
||||
const props = mockHealthCheckComponent.mock.calls[0][0];
|
||||
expect(props.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
|
||||
expect(props.all_models_on_proxy).not.toContain("gpt-4");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import HealthCheckComponent from "@/components/model_dashboard/HealthCheckComponent";
|
||||
import { getDisplayModelName } from "@/components/view_model/model_name_display";
|
||||
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
||||
const HEALTH_PAGE_SIZE = 50;
|
||||
|
||||
export default function HealthStatusPage() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
const { openModel } = useModelDetailRouting();
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: HEALTH_PAGE_SIZE });
|
||||
const { data: healthModelDataResponse, isLoading } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize);
|
||||
|
||||
const getProviderFromModel = useCallback(
|
||||
(model: string) => {
|
||||
if (modelCostMapData && typeof modelCostMapData === "object" && model in modelCostMapData) {
|
||||
return modelCostMapData[model]["litellm_provider"];
|
||||
}
|
||||
return "openai";
|
||||
},
|
||||
[modelCostMapData],
|
||||
);
|
||||
|
||||
const processedHealthModelData = useMemo(() => {
|
||||
if (!healthModelDataResponse?.data) {
|
||||
return { data: [] };
|
||||
}
|
||||
return transformModelData(healthModelDataResponse, getProviderFromModel);
|
||||
}, [healthModelDataResponse, getProviderFromModel]);
|
||||
|
||||
const healthModelIdsOnProxy = useMemo<string[]>(
|
||||
() =>
|
||||
healthModelDataResponse?.data
|
||||
?.map((model: any) => model.model_info?.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id)) ?? [],
|
||||
[healthModelDataResponse?.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<HealthCheckComponent
|
||||
accessToken={accessToken}
|
||||
modelData={processedHealthModelData}
|
||||
all_models_on_proxy={healthModelIdsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
setSelectedModelId={openModel}
|
||||
teams={teams ?? null}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
rowCount={healthModelDataResponse?.total_count ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsLayout from "./layout";
|
||||
|
||||
const { mockPush, mockReplace, navState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockReplace: vi.fn(),
|
||||
navState: { pathname: "/models-and-endpoints", search: "" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navState.pathname,
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useSearchParams: () => new URLSearchParams(navState.search),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
|
||||
vi.mock("@/components/model_info_view", () => ({
|
||||
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
|
||||
}));
|
||||
vi.mock("@/components/team/TeamInfo", () => ({
|
||||
default: ({ teamId }: { teamId: string }) => <div data-testid="team-info">team:{teamId}</div>,
|
||||
}));
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() }));
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => ({ data: { values: {} } }),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({
|
||||
useModelDashboardData: () => ({
|
||||
availableModelGroups: [],
|
||||
availableModelAccessGroups: [],
|
||||
allModelsOnProxy: [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderLayout = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelsAndEndpointsLayout>
|
||||
<div data-testid="tab-content">CHILD</div>
|
||||
</ModelsAndEndpointsLayout>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ModelsAndEndpointsLayout", () => {
|
||||
beforeEach(() => {
|
||||
navState.pathname = "/models-and-endpoints";
|
||||
navState.search = "";
|
||||
mockPush.mockClear();
|
||||
mockReplace.mockClear();
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Admin",
|
||||
userId: "123",
|
||||
premiumUser: false,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(global as any).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
it("renders the admin tab bar and the active tab's page content", () => {
|
||||
const { getByRole, getByTestId } = renderLayout();
|
||||
expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument();
|
||||
expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
|
||||
expect(getByTestId("tab-content")).toHaveTextContent("CHILD");
|
||||
});
|
||||
|
||||
it("navigates to a tab's path when its tab is clicked", async () => {
|
||||
const { getByRole } = renderLayout();
|
||||
await act(async () => {
|
||||
getByRole("tab", { name: "Health Status" }).click();
|
||||
});
|
||||
expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/health\/$/));
|
||||
});
|
||||
|
||||
it("redirects to the base models path when the tab path is not permitted for the role", async () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" },
|
||||
});
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "123",
|
||||
token: "123",
|
||||
userRole: "Internal User",
|
||||
userId: "123",
|
||||
premiumUser: false,
|
||||
});
|
||||
navState.pathname = "/models-and-endpoints/llm-credentials";
|
||||
await act(async () => {
|
||||
renderLayout();
|
||||
});
|
||||
expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/$/));
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("renders the model detail overlay from ?model and hides the tabs and page content", () => {
|
||||
navState.search = "model=abc-123";
|
||||
const { getByTestId, queryByTestId, queryByRole } = renderLayout();
|
||||
expect(getByTestId("model-info")).toHaveTextContent("model:abc-123");
|
||||
expect(queryByTestId("tab-content")).toBeNull();
|
||||
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the team detail overlay from ?team", () => {
|
||||
navState.search = "team=team-9";
|
||||
const { getByTestId, queryByTestId } = renderLayout();
|
||||
expect(getByTestId("team-info")).toHaveTextContent("team:team-9");
|
||||
expect(queryByTestId("tab-content")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Tabs } from "antd";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
|
||||
const BASE_TAB_KEY = "all-models";
|
||||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
health: "Health Status",
|
||||
"retry-settings": "Model Retry Settings",
|
||||
"model-group-alias": "Model Group Alias",
|
||||
"price-data": "Price Data Reload",
|
||||
};
|
||||
|
||||
export default function ModelsAndEndpointsLayout({ children }: { children: ReactNode }) {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const { data: teams, isLoading: teamsLoading } = useTeams();
|
||||
const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { modelId, teamId, close } = useModelDetailRouting();
|
||||
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
|
||||
|
||||
const [lastRefreshed, setLastRefreshed] = useState("");
|
||||
|
||||
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
|
||||
const isInternalUser = userRole && internalUserRoles.includes(userRole);
|
||||
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
|
||||
const addModelDisabledForInternalUsers =
|
||||
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
|
||||
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
|
||||
() => [
|
||||
"",
|
||||
...(shouldHideAddModelTab ? [] : (["add"] as const)),
|
||||
...(isAdmin
|
||||
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
|
||||
: []),
|
||||
],
|
||||
[shouldHideAddModelTab, isAdmin],
|
||||
);
|
||||
|
||||
const activeSlug = slugFromPathname(pathname);
|
||||
const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug);
|
||||
const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY;
|
||||
|
||||
useEffect(() => {
|
||||
if (teamsLoading || uiSettingsLoading) {
|
||||
return;
|
||||
}
|
||||
if (activeSlug !== "" && !isKnownSlug) {
|
||||
window.location.replace(modelTabHref(""));
|
||||
}
|
||||
}, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
const tabItems = visibleSlugs.map((slug) => {
|
||||
const key = slug || BASE_TAB_KEY;
|
||||
return {
|
||||
key,
|
||||
label: slug ? TAB_LABELS[slug] : allModelsLabel,
|
||||
children: key === activeKey ? children : null,
|
||||
};
|
||||
});
|
||||
|
||||
const handleRefreshClick = () => {
|
||||
setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
};
|
||||
|
||||
const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
|
||||
if (teamId) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<TeamInfoView
|
||||
teamId={teamId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
is_team_admin={userRole === "Admin"}
|
||||
is_proxy_admin={userRole === "Proxy Admin"}
|
||||
userModels={allModelsOnProxy}
|
||||
editTeam={false}
|
||||
onUpdate={invalidateModels}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 h-[75vh]">
|
||||
<div className="flex flex-col gap-2 p-8 w-full mt-2">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Model Management</h2>
|
||||
{isAdmin ? (
|
||||
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CostOptimizationFeedbackBanner />
|
||||
|
||||
{modelId ? (
|
||||
<ModelInfoView
|
||||
modelId={modelId}
|
||||
onClose={close}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
onModelUpdate={invalidateModels}
|
||||
modelAccessGroups={availableModelAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))}
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefreshClick}
|
||||
aria-label="Refresh models"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Form } from "antd";
|
||||
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
|
||||
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
|
||||
|
||||
export default function LlmCredentialsPage() {
|
||||
const [form] = Form.useForm();
|
||||
return <CredentialsPanel uploadProps={vertexCredentialsUploadProps(form)} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ModelGroupAliasSettings from "@/components/model_group_alias_settings";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function ModelGroupAliasPage() {
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await getCallbacksCall(accessToken, userID, userRole);
|
||||
if (active) {
|
||||
setModelGroupAlias(info.router_settings?.model_group_alias || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching model group alias:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
return (
|
||||
<ModelGroupAliasSettings
|
||||
accessToken={accessToken}
|
||||
initialModelGroupAlias={modelGroupAlias}
|
||||
onAliasUpdate={setModelGroupAlias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import ModelsAndEndpointsView from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useState } from "react";
|
||||
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
|
||||
export default function ModelsAndEndpointsPage() {
|
||||
const { premiumUser } = useAuthorized();
|
||||
const { data: teams } = useTeams();
|
||||
return <ModelsAndEndpointsView premiumUser={premiumUser} teams={teams ?? null} />;
|
||||
export default function AllModelsPage() {
|
||||
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
|
||||
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
|
||||
const { openModel, openTeam } = useModelDetailRouting();
|
||||
|
||||
return (
|
||||
<AllModelsTab
|
||||
selectedModelGroup={selectedModelGroup}
|
||||
setSelectedModelGroup={setSelectedModelGroup}
|
||||
availableModelGroups={availableModelGroups}
|
||||
availableModelAccessGroups={availableModelAccessGroups}
|
||||
setSelectedModelId={openModel}
|
||||
setSelectedTeamId={openTeam}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import PassThroughSettings from "@/components/PassThroughSettings/PassThroughSettings";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function PassThroughPage() {
|
||||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
return (
|
||||
<PassThroughSettings accessToken={accessToken} userRole={userRole} userID={userID} premiumUser={premiumUser} />
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
|
||||
|
||||
export default function PriceDataPage() {
|
||||
return <PriceDataManagementTab />;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
|
||||
import { getCallbacksCall } from "@/components/networking";
|
||||
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
interface RetryPolicyObject {
|
||||
[key: string]: { [retryPolicyKey: string]: number } | undefined;
|
||||
}
|
||||
|
||||
interface GlobalRetryPolicyObject {
|
||||
[retryPolicyKey: string]: number;
|
||||
}
|
||||
|
||||
interface RouterSettings {
|
||||
model_group_retry_policy?: RetryPolicyObject | null;
|
||||
retry_policy?: GlobalRetryPolicyObject | null;
|
||||
num_retries?: number | null;
|
||||
}
|
||||
|
||||
export default function ModelRetrySettingsPage() {
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
const { availableModelGroups } = useModelDashboardData();
|
||||
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
|
||||
|
||||
const [retryScope, setRetryScope] = useState<string | null>("global");
|
||||
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
|
||||
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
|
||||
const [defaultRetry, setDefaultRetry] = useState<number>(0);
|
||||
|
||||
const fetchRetrySettings = useCallback(async () => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const info = await getCallbacksCall(accessToken, userID, userRole);
|
||||
return info.router_settings;
|
||||
} catch (error) {
|
||||
console.error("Error fetching router settings:", error);
|
||||
return null;
|
||||
}
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
const applyRetrySettings = useCallback((routerSettings: RouterSettings) => {
|
||||
setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null);
|
||||
setGlobalRetryPolicy(routerSettings.retry_policy ?? null);
|
||||
setDefaultRetry(routerSettings.num_retries ?? 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void (async () => {
|
||||
const routerSettings = await fetchRetrySettings();
|
||||
if (active && routerSettings) {
|
||||
applyRetrySettings(routerSettings);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fetchRetrySettings, applyRetrySettings]);
|
||||
|
||||
const handleSaveRetrySettings = () => {
|
||||
updateRetryPolicy.mutate(
|
||||
{ retry_policy: globalRetryPolicy, model_group_retry_policy: modelGroupRetryPolicy },
|
||||
{
|
||||
onSuccess: () => {
|
||||
NotificationsManager.success("Retry settings saved successfully");
|
||||
void fetchRetrySettings().then((routerSettings) => {
|
||||
if (routerSettings) {
|
||||
applyRetrySettings(routerSettings);
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
NotificationsManager.fromBackend("Failed to save retry settings");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModelRetrySettingsTab
|
||||
selectedModelGroup={retryScope}
|
||||
setSelectedModelGroup={setRetryScope}
|
||||
availableModelGroups={availableModelGroups}
|
||||
globalRetryPolicy={globalRetryPolicy}
|
||||
setGlobalRetryPolicy={setGlobalRetryPolicy}
|
||||
defaultRetry={defaultRetry}
|
||||
modelGroupRetryPolicy={modelGroupRetryPolicy}
|
||||
setModelGroupRetryPolicy={setModelGroupRetryPolicy}
|
||||
handleSaveRetrySettings={handleSaveRetrySettings}
|
||||
isSaving={updateRetryPolicy.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { MODEL_TAB_SLUGS, modelTabHref, slugFromPathname } from "./tabRoutes";
|
||||
|
||||
describe("slugFromPathname", () => {
|
||||
it("returns empty string for the base path with or without a trailing slash", () => {
|
||||
expect(slugFromPathname("/models-and-endpoints")).toBe("");
|
||||
expect(slugFromPathname("/models-and-endpoints/")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
|
||||
expect(slugFromPathname("/models-and-endpoints/add")).toBe("add");
|
||||
expect(slugFromPathname("/ui/models-and-endpoints/llm-credentials/")).toBe("llm-credentials");
|
||||
});
|
||||
|
||||
it("returns the raw segment for an unknown tab so the view can redirect to base", () => {
|
||||
expect(slugFromPathname("/ui/models-and-endpoints/bogus")).toBe("bogus");
|
||||
});
|
||||
|
||||
it("returns empty string when the models base segment is not in the path", () => {
|
||||
expect(slugFromPathname("/teams")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelTabHref", () => {
|
||||
it("builds the trailing-slash base href for the empty slug", () => {
|
||||
expect(modelTabHref("")).toBe("/ui/models-and-endpoints/");
|
||||
});
|
||||
|
||||
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
|
||||
for (const slug of MODEL_TAB_SLUGS) {
|
||||
expect(modelTabHref(slug)).toBe(`/ui/models-and-endpoints/${slug}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
||||
export const MODELS_BASE_SEGMENT = "models-and-endpoints";
|
||||
|
||||
export const MODEL_TAB_SLUGS = [
|
||||
"add",
|
||||
"llm-credentials",
|
||||
"pass-through",
|
||||
"health",
|
||||
"retry-settings",
|
||||
"model-group-alias",
|
||||
"price-data",
|
||||
] as const;
|
||||
|
||||
export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number];
|
||||
|
||||
export function modelTabHref(slug: string): string {
|
||||
const base = migratedHref(MODELS_BASE_SEGMENT);
|
||||
return slug ? `${base}/${slug}/` : `${base}/`;
|
||||
}
|
||||
|
||||
export function slugFromPathname(pathname: string): string {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const idx = parts.indexOf(MODELS_BASE_SEGMENT);
|
||||
if (idx === -1) {
|
||||
return "";
|
||||
}
|
||||
return parts[idx + 1] ?? "";
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { useMemo } from "react";
|
||||
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
|
||||
export interface ModelDashboardData {
|
||||
availableModelGroups: string[];
|
||||
availableModelAccessGroups: string[];
|
||||
allModelsOnProxy: string[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useModelDashboardData(): ModelDashboardData {
|
||||
const { data: modelDataResponse, isLoading } = useModelsInfo();
|
||||
|
||||
const availableModelGroups = useMemo(() => {
|
||||
const groups = new Set<string>(modelDataResponse?.data?.map((model) => model.model_name) ?? []);
|
||||
return Array.from(groups).sort();
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const availableModelAccessGroups = useMemo(() => {
|
||||
const groups = new Set<string>(
|
||||
modelDataResponse?.data?.flatMap((model) => model.model_info?.access_groups ?? []) ?? [],
|
||||
);
|
||||
return Array.from(groups);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const allModelsOnProxy = useMemo(
|
||||
() => modelDataResponse?.data?.map((model) => model.model_name) ?? [],
|
||||
[modelDataResponse?.data],
|
||||
);
|
||||
|
||||
return { availableModelGroups, availableModelAccessGroups, allModelsOnProxy, isLoading };
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { FormInstance, UploadProps } from "antd";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
export function vertexCredentialsUploadProps(form: FormInstance): UploadProps {
|
||||
return {
|
||||
name: "file",
|
||||
accept: ".json",
|
||||
pastable: false,
|
||||
beforeUpload: (file) => {
|
||||
if (file.type === "application/json") {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target) {
|
||||
form.setFieldsValue({ vertex_credentials: event.target.result as string });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onChange(info) {
|
||||
if (info.file.status === "done") {
|
||||
NotificationsManager.success(`${info.file.name} file uploaded successfully`);
|
||||
} else if (info.file.status === "error") {
|
||||
NotificationsManager.fromBackend(`${info.file.name} file upload failed.`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue