mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-20 02:28:39 +00:00
healthcheck-model_id-fix: There was quite a bit of code that needed to be changed since health checks were entirely keyed by model name. This includes, proxy logic, the dashboard, and even networking, because model name was the identifier everywhere. All changed files had tests added to them, which are passing with no regressions.
This commit is contained in:
@@ -190,10 +190,15 @@ async def perform_health_check(
|
||||
model: Optional[str] = None,
|
||||
cli_model: Optional[str] = None,
|
||||
details: Optional[bool] = True,
|
||||
model_id: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Perform a health check on the system.
|
||||
|
||||
When model_id is provided, only the deployment with that id is checked
|
||||
(so models that share the same name but have different ids are checked separately).
|
||||
When model (name) is provided, all deployments matching that name are checked.
|
||||
|
||||
Returns:
|
||||
(bool): True if the health check passes, False otherwise.
|
||||
"""
|
||||
@@ -205,7 +210,12 @@ async def perform_health_check(
|
||||
else:
|
||||
return [], []
|
||||
|
||||
if model is not None:
|
||||
# Filter by model_id first so a single deployment is checked when id is specified
|
||||
if model_id is not None:
|
||||
_by_id = [x for x in model_list if (x.get("model_info") or {}).get("id") == model_id]
|
||||
if _by_id:
|
||||
model_list = _by_id
|
||||
elif model is not None:
|
||||
_new_model_list = [
|
||||
x for x in model_list if x["litellm_params"]["model"] == model
|
||||
]
|
||||
|
||||
@@ -238,7 +238,7 @@ async def health_services_endpoint( # noqa: PLR0915
|
||||
service_in_success_callbacks = True
|
||||
else:
|
||||
for cb in litellm.success_callback:
|
||||
if hasattr(cb, 'callback_name') and cb.callback_name == service:
|
||||
if getattr(cb, "callback_name", None) == service:
|
||||
service_in_success_callbacks = True
|
||||
break
|
||||
cb_id = get_callback_identifier(cb)
|
||||
@@ -732,7 +732,11 @@ async def _perform_health_check_and_save(
|
||||
):
|
||||
"""Helper function to perform health check and save results to database"""
|
||||
healthy_endpoints, unhealthy_endpoints = await perform_health_check(
|
||||
model_list=model_list, cli_model=cli_model, model=target_model, details=details
|
||||
model_list=model_list,
|
||||
cli_model=cli_model,
|
||||
model=target_model,
|
||||
details=details,
|
||||
model_id=model_id,
|
||||
)
|
||||
|
||||
# Optionally save health check result to database (non-blocking)
|
||||
|
||||
@@ -471,6 +471,50 @@ def test_update_litellm_params_for_health_check():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_perform_health_check_filters_by_model_id():
|
||||
"""
|
||||
When model_id is passed, only that deployment is checked (not all deployments
|
||||
that share the same model name).
|
||||
"""
|
||||
from litellm.proxy.health_check import perform_health_check
|
||||
|
||||
# Two deployments with same model_name but different ids
|
||||
model_list = [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model_info": {"id": "deployment-id-1"},
|
||||
"litellm_params": {"model": "gpt-4", "api_key": "fake-key-1"},
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model_info": {"id": "deployment-id-2"},
|
||||
"litellm_params": {"model": "gpt-4", "api_key": "fake-key-2"},
|
||||
},
|
||||
]
|
||||
|
||||
captured_list = []
|
||||
|
||||
async def mock_perform_health_check(m_list, details=True):
|
||||
captured_list.append(m_list)
|
||||
return [{"model": "gpt-4", "api_key": m_list[0]["litellm_params"]["api_key"]}], []
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.health_check._perform_health_check",
|
||||
side_effect=mock_perform_health_check,
|
||||
):
|
||||
healthy_endpoints, unhealthy_endpoints = await perform_health_check(
|
||||
model_list=model_list, model_id="deployment-id-2", details=True
|
||||
)
|
||||
|
||||
# Only one deployment (deployment-id-2) should have been passed to _perform_health_check
|
||||
assert len(captured_list) == 1
|
||||
assert len(captured_list[0]) == 1
|
||||
assert (captured_list[0][0].get("model_info") or {}).get("id") == "deployment-id-2"
|
||||
assert len(healthy_endpoints) == 1
|
||||
assert healthy_endpoints[0]["api_key"] == "fake-key-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_perform_health_check_with_health_check_model():
|
||||
"""
|
||||
|
||||
@@ -12,6 +12,7 @@ sys.path.insert(0, os.path.abspath("../../.."))
|
||||
from litellm.proxy.health_endpoints._health_endpoints import (
|
||||
_aggregate_health_check_results,
|
||||
_build_model_param_to_info_mapping,
|
||||
_perform_health_check_and_save,
|
||||
_save_background_health_checks_to_db,
|
||||
_save_health_check_results_if_changed,
|
||||
_save_health_check_to_db,
|
||||
@@ -466,5 +467,43 @@ async def test_get_all_latest_health_checks_without_model_id(mock_prisma):
|
||||
assert result[0].checked_at == mock_check2.checked_at # Latest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_perform_health_check_and_save_passes_model_id_to_perform_health_check():
|
||||
"""Test that _perform_health_check_and_save passes model_id to perform_health_check so health checks run by model id."""
|
||||
model_list = [
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"model_info": {"id": "deployment-abc"},
|
||||
"litellm_params": {"model": "gpt-4"},
|
||||
},
|
||||
]
|
||||
healthy = [{"model": "gpt-4"}]
|
||||
unhealthy = []
|
||||
|
||||
async def mock_perform_health_check(model_list, model=None, cli_model=None, details=True, model_id=None):
|
||||
return healthy, unhealthy
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.health_endpoints._health_endpoints.perform_health_check",
|
||||
side_effect=mock_perform_health_check,
|
||||
) as mock_perform:
|
||||
result = await _perform_health_check_and_save(
|
||||
model_list=model_list,
|
||||
target_model=None,
|
||||
cli_model=None,
|
||||
details=True,
|
||||
prisma_client=None,
|
||||
start_time=0.0,
|
||||
user_id="user-1",
|
||||
model_id="deployment-abc",
|
||||
)
|
||||
|
||||
mock_perform.assert_called_once()
|
||||
call_kwargs = mock_perform.call_args[1]
|
||||
assert call_kwargs["model_id"] == "deployment-abc"
|
||||
assert result["healthy_count"] == 1
|
||||
assert result["unhealthy_count"] == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
+50
-1
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelsAndEndpointsView from "./ModelsAndEndpointsView";
|
||||
|
||||
@@ -13,6 +13,8 @@ vi.mock("@/components/networking", () => ({
|
||||
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", () => ({
|
||||
@@ -27,6 +29,14 @@ 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: [],
|
||||
@@ -104,4 +114,43 @@ describe("ModelsAndEndpointsView", () => {
|
||||
);
|
||||
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
}, 15000);
|
||||
|
||||
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
|
||||
token="123"
|
||||
modelData={{ data: modelDataWithIds.data }}
|
||||
keys={[]}
|
||||
setModelData={() => {}}
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -98,6 +98,13 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
||||
return modelDataResponse.data.map((model: any) => model.model_name);
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const allModelIdsOnProxy = useMemo<string[]>(() => {
|
||||
if (!modelDataResponse?.data) return [];
|
||||
return modelDataResponse.data
|
||||
.map((model: any) => model.model_info?.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id));
|
||||
}, [modelDataResponse?.data]);
|
||||
|
||||
const getProviderFromModel = (model: string) => {
|
||||
if (modelCostMapData !== null && modelCostMapData !== undefined) {
|
||||
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
|
||||
@@ -397,7 +404,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
|
||||
<HealthCheckComponent
|
||||
accessToken={accessToken}
|
||||
modelData={processedModelData}
|
||||
all_models_on_proxy={allModelsOnProxy}
|
||||
all_models_on_proxy={allModelIdsOnProxy}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
setSelectedModelId={setSelectedModelId}
|
||||
teams={teams}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import HealthCheckComponent from "./HealthCheckComponent";
|
||||
|
||||
const mockIndividualModelHealthCheckCall = vi.fn();
|
||||
const mockLatestHealthChecksCall = vi.fn();
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
individualModelHealthCheckCall: (...args: unknown[]) => mockIndividualModelHealthCheckCall(...args),
|
||||
latestHealthChecksCall: (...args: unknown[]) => mockLatestHealthChecksCall(...args),
|
||||
}));
|
||||
|
||||
describe("HealthCheckComponent", () => {
|
||||
const getDisplayModelName = (model: { model_name?: string }) => model.model_name ?? "";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: {} });
|
||||
mockIndividualModelHealthCheckCall.mockResolvedValue({
|
||||
healthy_count: 1,
|
||||
unhealthy_count: 0,
|
||||
healthy_endpoints: [],
|
||||
unhealthy_endpoints: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the health check section", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "deployment-1" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["deployment-1"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(screen.getByText("Model Health Status")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Run health checks on individual models to verify they are working correctly"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call individualModelHealthCheckCall with model id when run health check is triggered", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "deployment-abc-123" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token-123"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["deployment-abc-123"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
|
||||
const runButtons = screen.getAllByTestId("run-health-check-btn");
|
||||
expect(runButtons.length).toBeGreaterThanOrEqual(1);
|
||||
const runButton = runButtons[0];
|
||||
|
||||
await act(async () => {
|
||||
runButton.click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token-123", "deployment-abc-123");
|
||||
expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token-123", "gpt-4");
|
||||
});
|
||||
|
||||
it("should key health status by model id and show status from latest_health_checks by model_id", async () => {
|
||||
const modelData = {
|
||||
data: [
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "id-alpha" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
{
|
||||
model_name: "gpt-4",
|
||||
model_info: { id: "id-beta" },
|
||||
litellm_model_name: "gpt-4",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLatestHealthChecksCall.mockResolvedValue({
|
||||
latest_health_checks: {
|
||||
"id-alpha": {
|
||||
status: "healthy",
|
||||
checked_at: "2024-01-15T10:00:00Z",
|
||||
error_message: null,
|
||||
},
|
||||
"id-beta": {
|
||||
status: "unhealthy",
|
||||
checked_at: "2024-01-15T10:05:00Z",
|
||||
error_message: "Connection failed",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<HealthCheckComponent
|
||||
accessToken="token"
|
||||
modelData={modelData}
|
||||
all_models_on_proxy={["id-alpha", "id-beta"]}
|
||||
getDisplayModelName={getDisplayModelName}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(mockLatestHealthChecksCall).toHaveBeenCalledWith("token");
|
||||
const healthyBadges = screen.getAllByText("healthy");
|
||||
const unhealthyBadges = screen.getAllByText("unhealthy");
|
||||
expect(healthyBadges.length).toBeGreaterThanOrEqual(1);
|
||||
expect(unhealthyBadges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -53,31 +53,33 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
|
||||
const healthTableRef = useRef<TableInstance<any>>(null);
|
||||
|
||||
// Initialize health statuses on component mount
|
||||
// Initialize health statuses on component mount (keyed by model id)
|
||||
useEffect(() => {
|
||||
if (!accessToken || !modelData?.data) return;
|
||||
|
||||
const initializeHealthStatuses = async () => {
|
||||
const healthStatusMap: { [key: string]: HealthStatus } = {};
|
||||
|
||||
// Initialize all models with default state using model names
|
||||
// Initialize all models with default state using model ids
|
||||
modelData.data.forEach((model: any) => {
|
||||
const modelName = model.model_name;
|
||||
healthStatusMap[modelName] = {
|
||||
status: "none",
|
||||
lastCheck: "None",
|
||||
lastSuccess: "None",
|
||||
loading: false,
|
||||
error: undefined,
|
||||
fullError: undefined,
|
||||
successResponse: undefined,
|
||||
};
|
||||
const modelId = model.model_info?.id;
|
||||
if (modelId) {
|
||||
healthStatusMap[modelId] = {
|
||||
status: "none",
|
||||
lastCheck: "None",
|
||||
lastSuccess: "None",
|
||||
loading: false,
|
||||
error: undefined,
|
||||
fullError: undefined,
|
||||
successResponse: undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const latestHealthChecks = await latestHealthChecksCall(accessToken);
|
||||
|
||||
// Override with actual database data if it exists
|
||||
// Override with actual database data if it exists (latest_health_checks is keyed by model_id)
|
||||
if (
|
||||
latestHealthChecks &&
|
||||
latestHealthChecks.latest_health_checks &&
|
||||
@@ -86,32 +88,28 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
Object.entries(latestHealthChecks.latest_health_checks).forEach(([key, checkData]: [string, any]) => {
|
||||
if (!checkData) return;
|
||||
|
||||
let targetModelName: string | null = null;
|
||||
let targetModelId: string | null = null;
|
||||
|
||||
// The key could be either model_id or model_name, try both approaches
|
||||
const directModelMatch = modelData.data.find((m: any) => m.model_name === key);
|
||||
if (directModelMatch) {
|
||||
targetModelName = directModelMatch.model_name;
|
||||
// The key is model_id from the backend; fallback to matching by model_name for legacy data
|
||||
const modelByIdMatch = modelData.data.find((m: any) => m.model_info && m.model_info.id === key);
|
||||
if (modelByIdMatch) {
|
||||
targetModelId = modelByIdMatch.model_info.id;
|
||||
} else {
|
||||
// If not a direct match, treat as model_id and find the corresponding model
|
||||
const modelByIdMatch = modelData.data.find((m: any) => m.model_info && m.model_info.id === key);
|
||||
if (modelByIdMatch) {
|
||||
targetModelName = modelByIdMatch.model_name;
|
||||
} else {
|
||||
// Check if checkData contains model_name and use that
|
||||
if (checkData.model_name) {
|
||||
const modelByNameInData = modelData.data.find((m: any) => m.model_name === checkData.model_name);
|
||||
if (modelByNameInData) {
|
||||
targetModelName = modelByNameInData.model_name;
|
||||
}
|
||||
const directModelMatch = modelData.data.find((m: any) => m.model_name === key);
|
||||
if (directModelMatch?.model_info?.id) {
|
||||
targetModelId = directModelMatch.model_info.id;
|
||||
} else if (checkData.model_name) {
|
||||
const modelByNameInData = modelData.data.find((m: any) => m.model_name === checkData.model_name);
|
||||
if (modelByNameInData?.model_info?.id) {
|
||||
targetModelId = modelByNameInData.model_info.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetModelName) {
|
||||
if (targetModelId) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
|
||||
healthStatusMap[targetModelName] = {
|
||||
healthStatusMap[targetModelId] = {
|
||||
status: checkData.status || "unknown",
|
||||
lastCheck: checkData.checked_at ? new Date(checkData.checked_at).toLocaleString() : "None",
|
||||
lastSuccess:
|
||||
@@ -246,33 +244,31 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
return cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned;
|
||||
};
|
||||
|
||||
const runIndividualHealthCheck = async (modelName: string) => {
|
||||
const runIndividualHealthCheck = async (modelId: string) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
...prev[modelName],
|
||||
[modelId]: {
|
||||
...prev[modelId],
|
||||
loading: true,
|
||||
status: "checking",
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
// Run the health check and process the response directly
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelName);
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelId);
|
||||
const currentTime = new Date().toLocaleString();
|
||||
|
||||
// Check if there are any unhealthy endpoints (which means this specific model failed)
|
||||
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
|
||||
const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed";
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: "unhealthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: prev[modelName]?.lastSuccess || "None",
|
||||
lastSuccess: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
fullError: rawError,
|
||||
@@ -281,7 +277,7 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
} else {
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: "healthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: currentTime,
|
||||
@@ -291,41 +287,33 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
}));
|
||||
}
|
||||
|
||||
// Refresh health status from database to get the saved check data including timestamp
|
||||
try {
|
||||
const latestHealthChecks = await latestHealthChecksCall(accessToken);
|
||||
const checkData = latestHealthChecks.latest_health_checks?.[modelId];
|
||||
|
||||
// Find the model ID for this model name to look up database data
|
||||
const model = modelData.data.find((m: any) => m.model_name === modelName);
|
||||
if (model) {
|
||||
const modelId = model.model_info.id;
|
||||
const checkData = latestHealthChecks.latest_health_checks?.[modelId];
|
||||
|
||||
if (checkData) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
status: checkData.status || prev[modelName]?.status || "unknown",
|
||||
lastCheck: checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: prev[modelName]?.lastCheck || "None",
|
||||
lastSuccess:
|
||||
checkData.status === "healthy"
|
||||
? checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: prev[modelName]?.lastSuccess || "None"
|
||||
: prev[modelName]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : prev[modelName]?.error,
|
||||
fullError: fullError || prev[modelName]?.fullError,
|
||||
successResponse: checkData.status === "healthy" ? checkData : prev[modelName]?.successResponse,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (checkData) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelId]: {
|
||||
status: checkData.status || prev[modelId]?.status || "unknown",
|
||||
lastCheck: checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: prev[modelId]?.lastCheck || "None",
|
||||
lastSuccess:
|
||||
checkData.status === "healthy"
|
||||
? checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
: prev[modelId]?.lastSuccess || "None"
|
||||
: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: fullError ? extractMeaningfulError(fullError) : prev[modelId]?.error,
|
||||
fullError: fullError || prev[modelId]?.fullError,
|
||||
successResponse: checkData.status === "healthy" ? checkData : prev[modelId]?.successResponse,
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (dbError) {
|
||||
// Ignore database errors - we already have the health check result from the API call
|
||||
console.debug("Could not fetch updated status from database (non-critical):", dbError);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -334,10 +322,10 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: "unhealthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: prev[modelName]?.lastSuccess || "None",
|
||||
lastSuccess: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
fullError: rawError,
|
||||
@@ -349,11 +337,10 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
const runAllHealthChecks = async () => {
|
||||
const modelsToCheck = selectedModelsForHealth.length > 0 ? selectedModelsForHealth : all_models_on_proxy;
|
||||
|
||||
// Set all models to loading state
|
||||
const loadingStatuses = modelsToCheck.reduce(
|
||||
(acc, modelName) => {
|
||||
acc[modelName] = {
|
||||
...modelHealthStatuses[modelName],
|
||||
(acc, modelId) => {
|
||||
acc[modelId] = {
|
||||
...modelHealthStatuses[modelId],
|
||||
loading: true,
|
||||
status: "checking",
|
||||
};
|
||||
@@ -364,30 +351,25 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
|
||||
setModelHealthStatuses((prev) => ({ ...prev, ...loadingStatuses }));
|
||||
|
||||
// Store results from individual health checks
|
||||
const healthCheckResults: { [key: string]: any } = {};
|
||||
|
||||
// Run all health checks in parallel and collect results
|
||||
const healthCheckPromises = modelsToCheck.map(async (modelName) => {
|
||||
const healthCheckPromises = modelsToCheck.map(async (modelId) => {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
// Run the health check and store the result
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelName);
|
||||
healthCheckResults[modelName] = response;
|
||||
const response = await individualModelHealthCheckCall(accessToken, modelId);
|
||||
healthCheckResults[modelId] = response;
|
||||
|
||||
// Update status immediately based on response
|
||||
const currentTime = new Date().toLocaleString();
|
||||
// Check if there are any unhealthy endpoints (which means this specific model failed)
|
||||
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
|
||||
const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed";
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: "unhealthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: prev[modelName]?.lastSuccess || "None",
|
||||
lastSuccess: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
fullError: rawError,
|
||||
@@ -396,7 +378,7 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
} else {
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: "healthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: currentTime,
|
||||
@@ -406,17 +388,16 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Health check failed for ${modelName}:`, error);
|
||||
// Set error status for failed health checks
|
||||
console.error(`Health check failed for model id ${modelId}:`, error);
|
||||
const currentTime = new Date().toLocaleString();
|
||||
const rawError = error instanceof Error ? error.message : String(error);
|
||||
const errorMessage = extractMeaningfulError(rawError);
|
||||
setModelHealthStatuses((prev) => ({
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: "unhealthy",
|
||||
lastCheck: currentTime,
|
||||
lastSuccess: prev[modelName]?.lastSuccess || "None",
|
||||
lastSuccess: prev[modelId]?.lastSuccess || "None",
|
||||
loading: false,
|
||||
error: errorMessage,
|
||||
fullError: rawError,
|
||||
@@ -425,27 +406,21 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all health checks to complete
|
||||
await Promise.allSettled(healthCheckPromises);
|
||||
|
||||
// Refresh health statuses from database to get the saved check data including timestamps
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
const latestHealthChecks = await latestHealthChecksCall(accessToken);
|
||||
|
||||
if (latestHealthChecks.latest_health_checks) {
|
||||
// Update health statuses from database, which should have the most accurate saved data
|
||||
Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, checkData]: [string, any]) => {
|
||||
// Find the model name for this model ID
|
||||
const model = modelData.data.find((m: any) => m.model_info.id === modelId);
|
||||
if (model && modelsToCheck.includes(model.model_name) && checkData) {
|
||||
const modelName = model.model_name;
|
||||
if (modelsToCheck.includes(modelId) && checkData) {
|
||||
const fullError = checkData.error_message || undefined;
|
||||
setModelHealthStatuses((prev) => {
|
||||
const currentStatus = prev[modelName];
|
||||
const currentStatus = prev[modelId];
|
||||
return {
|
||||
...prev,
|
||||
[modelName]: {
|
||||
[modelId]: {
|
||||
status: checkData.status || currentStatus?.status || "unknown",
|
||||
lastCheck: checkData.checked_at
|
||||
? new Date(checkData.checked_at).toLocaleString()
|
||||
@@ -468,15 +443,14 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.warn("Failed to fetch updated health statuses from database (non-critical):", dbError);
|
||||
// This is non-critical - we already have the health check results from the API calls
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelection = (modelName: string, checked: boolean) => {
|
||||
const handleModelSelection = (modelId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedModelsForHealth((prev) => [...prev, modelName]);
|
||||
setSelectedModelsForHealth((prev) => [...prev, modelId]);
|
||||
} else {
|
||||
setSelectedModelsForHealth((prev) => prev.filter((name) => name !== modelName));
|
||||
setSelectedModelsForHealth((prev) => prev.filter((id) => id !== modelId));
|
||||
setAllModelsSelected(false);
|
||||
}
|
||||
};
|
||||
@@ -580,8 +554,9 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
teams,
|
||||
)}
|
||||
data={modelData.data.map((model: any) => {
|
||||
const modelName = model.model_name;
|
||||
const healthStatus = modelHealthStatuses[modelName] || {
|
||||
const modelId = model.model_info?.id;
|
||||
const healthStatus = modelId ? modelHealthStatuses[modelId] : null;
|
||||
const status = healthStatus || {
|
||||
status: "none",
|
||||
lastCheck: "None",
|
||||
loading: false,
|
||||
@@ -591,12 +566,12 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
|
||||
model_info: model.model_info,
|
||||
provider: model.provider,
|
||||
litellm_model_name: model.litellm_model_name,
|
||||
health_status: healthStatus.status,
|
||||
last_check: healthStatus.lastCheck,
|
||||
last_success: healthStatus.lastSuccess || "None",
|
||||
health_loading: healthStatus.loading,
|
||||
health_error: healthStatus.error,
|
||||
health_full_error: healthStatus.fullError,
|
||||
health_status: status.status,
|
||||
last_check: status.lastCheck,
|
||||
last_success: status.lastSuccess || "None",
|
||||
health_loading: status.loading,
|
||||
health_error: status.error,
|
||||
health_full_error: status.fullError,
|
||||
};
|
||||
})}
|
||||
isLoading={false}
|
||||
|
||||
@@ -35,9 +35,9 @@ export const healthCheckColumns = (
|
||||
modelHealthStatuses: { [key: string]: HealthStatus },
|
||||
selectedModelsForHealth: string[],
|
||||
allModelsSelected: boolean,
|
||||
handleModelSelection: (modelName: string, checked: boolean) => void,
|
||||
handleModelSelection: (modelId: string, checked: boolean) => void,
|
||||
handleSelectAll: (checked: boolean) => void,
|
||||
runIndividualHealthCheck: (modelName: string) => void,
|
||||
runIndividualHealthCheck: (modelId: string) => void,
|
||||
getStatusBadge: (status: string) => JSX.Element,
|
||||
getDisplayModelName: (model: any) => string,
|
||||
showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void,
|
||||
@@ -62,14 +62,14 @@ export const healthCheckColumns = (
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelName = model.model_name;
|
||||
const isSelected = selectedModelsForHealth.includes(modelName);
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const isSelected = selectedModelsForHealth.includes(modelId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleModelSelection(modelName, e.target.checked)}
|
||||
onChange={(e) => handleModelSelection(modelId, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tooltip title={model.model_info.id}>
|
||||
@@ -169,8 +169,9 @@ export const healthCheckColumns = (
|
||||
);
|
||||
}
|
||||
|
||||
const modelName = model.model_name;
|
||||
const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelName]?.successResponse;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelId]?.successResponse;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -178,7 +179,7 @@ export const healthCheckColumns = (
|
||||
{hasSuccessResponse && showSuccessModal && (
|
||||
<Tooltip title="View response details" placement="top">
|
||||
<button
|
||||
onClick={() => showSuccessModal(modelName, modelHealthStatuses[modelName]?.successResponse)}
|
||||
onClick={() => showSuccessModal(displayName, modelHealthStatuses[modelId]?.successResponse)}
|
||||
className="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
@@ -195,8 +196,9 @@ export const healthCheckColumns = (
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelName = model.model_name;
|
||||
const healthStatus = modelHealthStatuses[modelName];
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const displayName = getDisplayModelName(model) || model.model_name;
|
||||
const healthStatus = modelHealthStatuses[modelId];
|
||||
|
||||
if (!healthStatus?.error) {
|
||||
return <Text className="text-gray-400 text-sm">No errors</Text>;
|
||||
@@ -215,7 +217,7 @@ export const healthCheckColumns = (
|
||||
{showErrorModal && fullError !== cleanedError && (
|
||||
<Tooltip title="View full error details" placement="top">
|
||||
<button
|
||||
onClick={() => showErrorModal(modelName, cleanedError, fullError)}
|
||||
onClick={() => showErrorModal(displayName, cleanedError, fullError)}
|
||||
className="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
<InformationCircleIcon className="h-4 w-4" />
|
||||
@@ -294,8 +296,8 @@ export const healthCheckColumns = (
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelName = model.model_name;
|
||||
const healthStatus = modelHealthStatuses[modelName];
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
const healthStatus = modelHealthStatuses[modelId];
|
||||
const lastSuccess = healthStatus?.lastSuccess || "None";
|
||||
|
||||
return <Text className="text-gray-600 text-sm">{lastSuccess}</Text>;
|
||||
@@ -306,7 +308,7 @@ export const healthCheckColumns = (
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const model = row.original;
|
||||
const modelName = model.model_name;
|
||||
const modelId = model.model_info?.id ?? "";
|
||||
|
||||
const hasExistingStatus = model.health_status && model.health_status !== "none";
|
||||
const tooltipText = model.health_loading
|
||||
@@ -318,6 +320,7 @@ export const healthCheckColumns = (
|
||||
return (
|
||||
<Tooltip title={tooltipText} placement="top">
|
||||
<button
|
||||
data-testid="run-health-check-btn"
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
model.health_loading
|
||||
? "text-gray-400 cursor-not-allowed bg-gray-100"
|
||||
@@ -325,7 +328,7 @@ export const healthCheckColumns = (
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!model.health_loading) {
|
||||
runIndividualHealthCheck(modelName);
|
||||
runIndividualHealthCheck(modelId);
|
||||
}
|
||||
}}
|
||||
disabled={model.health_loading}
|
||||
|
||||
@@ -317,3 +317,57 @@ describe("UI config and public endpoints", () => {
|
||||
expect(configCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("individualModelHealthCheckCall", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("should call /health with model_id query param so health checks run by deployment id", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
healthy_count: 1,
|
||||
unhealthy_count: 0,
|
||||
healthy_endpoints: [],
|
||||
unhealthy_endpoints: [],
|
||||
}),
|
||||
} as any);
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
await Networking.individualModelHealthCheckCall("token-123", "deployment-abc-456");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
const urlStr = typeof url === "string" ? url : (url as Request).url;
|
||||
expect(urlStr).toContain("health");
|
||||
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
|
||||
expect(parsed.searchParams.get("model_id")).toBe("deployment-abc-456");
|
||||
expect(parsed.searchParams.has("model")).toBe(false);
|
||||
});
|
||||
|
||||
it("should encode model_id in URL", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
healthy_count: 0,
|
||||
unhealthy_count: 0,
|
||||
healthy_endpoints: [],
|
||||
unhealthy_endpoints: [],
|
||||
}),
|
||||
} as any);
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
await Networking.individualModelHealthCheckCall("token", "id/with/slashes");
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
|
||||
expect(parsed.searchParams.get("model_id")).toBe("id/with/slashes");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5001,14 +5001,14 @@ export const healthCheckCall = async (accessToken: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const individualModelHealthCheckCall = async (accessToken: string, modelName: string) => {
|
||||
export const individualModelHealthCheckCall = async (accessToken: string, modelId: string) => {
|
||||
/**
|
||||
* Run health check for a specific model using model name
|
||||
* Run health check for a specific model using model ID (so each deployment is checked separately).
|
||||
*/
|
||||
try {
|
||||
let url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/health?model=${encodeURIComponent(modelName)}`
|
||||
: `/health?model=${encodeURIComponent(modelName)}`;
|
||||
? `${proxyBaseUrl}/health?model_id=${encodeURIComponent(modelId)}`
|
||||
: `/health?model_id=${encodeURIComponent(modelId)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
@@ -5028,7 +5028,7 @@ export const individualModelHealthCheckCall = async (accessToken: string, modelN
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Failed to call /health for model ${modelName}:`, error);
|
||||
console.error(`Failed to call /health for model id ${modelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user